diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000000..2a63216be2 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/@effect/tsgo/oxlint-schema.json", + "extends": ["./node_modules/@effect/tsgo/oxlint-presets/recommended.json"] +} diff --git a/apps/cli-e2e/package.json b/apps/cli-e2e/package.json index 3d34d2ca2b..cc100ecde0 100644 --- a/apps/cli-e2e/package.json +++ b/apps/cli-e2e/package.json @@ -16,9 +16,11 @@ "@supabase/cli-test-helpers": "workspace:*" }, "devDependencies": { + "@effect/platform-bun": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@vitest/coverage-istanbul": "catalog:", + "effect": "catalog:", "knip": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", diff --git a/apps/cli-e2e/src/server/fixture-loader.ts b/apps/cli-e2e/src/server/fixture-loader.ts index 50da310d26..33978ff28d 100644 --- a/apps/cli-e2e/src/server/fixture-loader.ts +++ b/apps/cli-e2e/src/server/fixture-loader.ts @@ -1,5 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { Data, Effect, FileSystem, Path, Schema } from "effect"; export interface FixtureRequest { method: string; @@ -24,74 +23,114 @@ export interface FixtureEntry { * an ordered queue of entries (for sequential calls to the same endpoint). */ export type FixtureStore = Map; +class FixtureLoadError extends Data.TaggedError("FixtureLoadError")<{ + readonly path: string; + readonly cause?: unknown; +}> {} + +type FixtureLoadEffect = Effect.Effect; + +const parseJson = Schema.fromJsonString(Schema.Unknown); + +function parseFixtureFile(path: string): FixtureLoadEffect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if ( + !(yield* fs + .exists(path) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path, cause })))) + ) { + return yield* new FixtureLoadError({ path, cause: `Missing fixture file: ${path}` }); + } + const content = yield* fs + .readFileString(path) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path, cause }))); + const value = yield* Schema.decodeEffect(parseJson)(content).pipe( + Effect.mapError((cause) => new FixtureLoadError({ path, cause })), + ); + return value as T; + }); +} + /** Load an ordered scenario from scenarios//interactions.json. * Returns null if the scenario file does not exist — caller decides whether * to fail loudly or fall back to per-endpoint fixtures. */ -export function loadScenario(scenariosDir: string, name: string): FixtureEntry[] | null { - const scenarioFile = join(scenariosDir, name, "interactions.json"); - if (!existsSync(scenarioFile)) return null; - return parseFixtureFile(scenarioFile); +export function loadScenario( + scenariosDir: string, + name: string, +): FixtureLoadEffect { + return Effect.gen(function* () { + const path = yield* Path.Path; + const scenarioFile = path.join(scenariosDir, name, "interactions.json"); + const fs = yield* FileSystem.FileSystem; + if ( + !(yield* fs + .exists(scenarioFile) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path: scenarioFile, cause })))) + ) + return null; + return yield* parseFixtureFile(scenarioFile); + }); } /** Load all fixture pairs from the recorded/ directory into a FixtureStore. * Fails fast with a descriptive error if any fixture file is malformed. */ -export function loadFixtures(fixturesDir: string): FixtureStore { - const recordedDir = join(fixturesDir, "recorded"); - const store: FixtureStore = new Map(); - - if (!existsSync(recordedDir)) { - return store; - } - - const keys = readdirSync(recordedDir, { withFileTypes: true }) - .filter((d) => d.isDirectory()) - .map((d) => d.name); - - for (const key of keys) { - const keyDir = join(recordedDir, key); - const entries: FixtureEntry[] = []; - - // Collect numbered pairs: 1.request.json/1.response.json, 2.request.json, ... - // Also accept "default" as an alias for "1". - const files = readdirSync(keyDir).sort(); - const indices = new Set(); - - for (const file of files) { - const match = file.match(/^(\d+|default)\.(request|response)\.json$/); - if (match?.[1]) indices.add(match[1]); +export function loadFixtures(fixturesDir: string): FixtureLoadEffect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const recordedDir = path.join(fixturesDir, "recorded"); + const store: FixtureStore = new Map(); + + if ( + !(yield* fs + .exists(recordedDir) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path: recordedDir, cause })))) + ) + return store; + + const directoryEntries = yield* fs + .readDirectory(recordedDir) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path: recordedDir, cause }))); + const keys: Array = []; + for (const entry of directoryEntries) { + const info = yield* fs + .stat(path.join(recordedDir, entry)) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path: recordedDir, cause }))); + if (info.type === "Directory") keys.push(entry); } - for (const index of [...indices].sort(compareIndices)) { - const reqFile = join(keyDir, `${index}.request.json`); - const resFile = join(keyDir, `${index}.response.json`); - - const request = parseFixtureFile(reqFile); - const response = parseFixtureFile(resFile); - entries.push({ request, response }); + for (const key of keys) { + const keyDir = path.join(recordedDir, key); + const files = (yield* fs + .readDirectory(keyDir) + .pipe(Effect.mapError((cause) => new FixtureLoadError({ path: keyDir, cause })))).sort(); + const indices = new Set(); + + for (const file of files) { + const match = /^(\d+|default)\.(request|response)\.json$/.exec(file); + if (match?.[1] !== undefined) indices.add(match[1]); + } + + const entries: FixtureEntry[] = []; + for (const index of [...indices].sort(compareIndices)) { + const [request, response] = yield* Effect.all([ + parseFixtureFile(path.join(keyDir, `${index}.request.json`)), + parseFixtureFile(path.join(keyDir, `${index}.response.json`)), + ]); + entries.push({ request, response }); + } + + if (entries.length > 0) store.set(key, entries); } - if (entries.length > 0) { - store.set(key, entries); - } - } - - return store; -} - -function parseFixtureFile(filePath: string): T { - if (!existsSync(filePath)) { - throw new Error(`Missing fixture file: ${filePath}`); - } - try { - return JSON.parse(readFileSync(filePath, "utf8")) as T; - } catch (cause) { - throw new Error(`Malformed fixture file: ${filePath}`, { cause }); - } + return store; + }); } /** Sort indices so "default" comes first, then numerically. */ function compareIndices(a: string, b: string): number { if (a === "default") return -1; if (b === "default") return 1; - return parseInt(a, 10) - parseInt(b, 10); + return Number.parseInt(a, 10) - Number.parseInt(b, 10); } diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index a5a734f28a..dd9089fcee 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect"; + /** * Minimal Postgres wire protocol mock server (Bun.listen TCP). * @@ -26,7 +28,7 @@ // Public types // --------------------------------------------------------------------------- -export interface PgFixture { +interface PgFixture { /** Lowercase column names matching Go Result struct field names. */ columns: string[]; /** @@ -466,13 +468,13 @@ export function startPgMock(): PgMockHandle { try { processMessages(socket, () => state); } catch (err) { - console.error("[pg-mock] error processing message:", err); + Effect.runSync(Effect.logError("[pg-mock] error processing message", err)); socket.end(); } }, close(_socket) {}, error(_socket, err) { - console.error("[pg-mock] socket error:", err); + Effect.runSync(Effect.logError("[pg-mock] socket error", err)); }, }, }); diff --git a/apps/cli-e2e/src/server/replay-server.e2e.test.ts b/apps/cli-e2e/src/server/replay-server.e2e.test.ts new file mode 100644 index 0000000000..febbe3e0f3 --- /dev/null +++ b/apps/cli-e2e/src/server/replay-server.e2e.test.ts @@ -0,0 +1,190 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "vitest"; +import { + Deferred, + Duration, + Effect, + Fiber, + FileSystem, + Layer, + Option, + Path, + Schema, + Stream, +} from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { fixtureKey } from "./placeholder.ts"; +import { startReplayServer } from "./replay-server.ts"; + +const RecordedResponseSchema = Schema.Struct({ + body: Schema.Struct({ value: Schema.String }), +}); + +describe("replay server recording", () => { + it("keeps concurrent recordings for one normalized fixture key", () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixturesDir = yield* fs.makeTempDirectory({ prefix: "supabase-replay-recording-" }); + const firstRef = "aaaaaaaaaaaaaaaaaaaa"; + const secondRef = "bbbbbbbbbbbbbbbbbbbb"; + const bothRequests = yield* Deferred.make(); + const serverContext = yield* Effect.context(); + let upstreamRequestCount = 0; + const upstream = Bun.serve({ + port: 0, + fetch: (request) => + Effect.runPromiseWith(serverContext)( + Effect.gen(function* () { + const pathname = new URL(request.url).pathname; + if (!pathname.startsWith("/v1/projects/")) { + return new Response(null, { status: 404 }); + } + + upstreamRequestCount += 1; + if (upstreamRequestCount === 2) { + yield* Deferred.succeed(bothRequests, undefined); + } + yield* Deferred.await(bothRequests); + + return Response.json({ + value: pathname.endsWith(firstRef) ? "first" : "second", + }); + }), + ), + }); + + const replay = yield* Effect.tryPromise(() => + startReplayServer({ + fixturesDir, + mode: "record", + stagingUrl: `http://127.0.0.1:${upstream.port}`, + }), + ); + + yield* Effect.gen(function* () { + const [firstResponse, secondResponse] = yield* Effect.all( + [ + HttpClient.execute(HttpClientRequest.get(`${replay.url}/v1/projects/${firstRef}`)), + HttpClient.execute(HttpClientRequest.get(`${replay.url}/v1/projects/${secondRef}`)), + ], + { concurrency: "unbounded" }, + ); + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(yield* Effect.all([firstResponse.json, secondResponse.json])).toEqual([ + { value: "first" }, + { value: "second" }, + ]); + + const keyDir = path.join( + fixturesDir, + "recorded", + fixtureKey("GET", `/v1/projects/${firstRef}`), + ); + expect(new Set(yield* fs.readDirectory(keyDir))).toEqual( + new Set([ + "default.request.json", + "default.response.json", + "2.request.json", + "2.response.json", + ]), + ); + const responseBodies = yield* Effect.all( + ["default", "2"].map((index) => + Effect.gen(function* () { + return yield* Schema.decodeEffect(Schema.fromJsonString(RecordedResponseSchema))( + yield* fs.readFileString(path.join(keyDir, `${index}.response.json`)), + ); + }), + ), + ); + expect( + responseBodies.map((body) => body.body.value).sort((a, b) => a.localeCompare(b)), + ).toEqual(["first", "second"]); + }).pipe( + Effect.ensuring( + Effect.promise(() => replay.stop()).pipe( + Effect.andThen(Effect.promise(() => Promise.resolve(upstream.stop()))), + Effect.andThen(fs.remove(fixturesDir, { recursive: true, force: true })), + Effect.ignore, + ), + ), + ); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + Effect.orDie, + ), + )); + + it("waits for detached Docker recordings before stopping", () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fixturesDir = yield* fs.makeTempDirectory({ prefix: "supabase-replay-drain-" }); + const socketPath = path.join(fixturesDir, "docker.sock"); + const bodyStarted = yield* Deferred.make(); + const releaseBody = yield* Deferred.make(); + const serverContext = yield* Effect.context(); + const docker = Bun.serve({ + unix: socketPath, + fetch: () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"ok":true}')); + void Effect.runPromiseWith(serverContext)( + Effect.gen(function* () { + yield* Deferred.succeed(bodyStarted, undefined); + yield* Deferred.await(releaseBody); + controller.close(); + }), + ); + }, + }), + ), + }); + const replay = yield* Effect.tryPromise(() => + startReplayServer({ + fixturesDir, + mode: "record", + stagingUrl: "http://127.0.0.1:1", + }), + ); + replay.setDockerProxyUrl(socketPath); + + yield* Effect.gen(function* () { + const response = yield* HttpClient.execute( + HttpClientRequest.get(`${replay.url}/v1.47/info`), + ); + expect(response.status).toBe(200); + yield* Stream.runHead(response.stream); + yield* Deferred.await(bodyStarted); + + const stopFiber = yield* Effect.tryPromise(() => replay.stop()).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const stoppedBeforeBody = yield* Fiber.await(stopFiber).pipe( + Effect.timeoutOption(Duration.millis(100)), + ); + expect(Option.isNone(stoppedBeforeBody)).toBe(true); + + yield* Deferred.succeed(releaseBody, undefined); + yield* Fiber.await(stopFiber); + }).pipe( + Effect.ensuring( + Deferred.succeed(releaseBody, undefined).pipe( + Effect.andThen(Effect.promise(() => Promise.resolve(docker.stop()))), + Effect.andThen(fs.remove(fixturesDir, { recursive: true, force: true })), + Effect.ignore, + ), + ), + ); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)), + Effect.orDie, + ), + )); +}); diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 7b0491e2bb..27595c5ad5 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -1,6 +1,19 @@ -import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { request as httpRequest } from "node:http"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { + DateTime, + Duration, + Effect, + FileSystem, + Fiber, + Layer, + Logger, + Option, + Path, + Schema, + Semaphore, + Stream, +} from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { URL } from "node:url"; import type { FixtureEntry, @@ -17,7 +30,23 @@ import { restoreProjectRef, } from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; -import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; +import type { PgMockHandle } from "./pg-mock.ts"; + +const pathApi = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const join = (...parts: ReadonlyArray): string => pathApi.join(...parts); + +function runFs(effect: Effect.Effect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer), Effect.orDie)); +} + +function removePath(path: string): Promise { + return runFs( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true, force: true }); + }), + ); +} interface RecordedRequest { method: string; @@ -69,6 +98,54 @@ interface RawBody { base64: string; } +const FixtureEntrySchema = Schema.Struct({ + request: Schema.Struct({ + method: Schema.String, + path: Schema.String, + query: Schema.Record(Schema.String, Schema.String), + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Unknown, + }), + response: Schema.Struct({ + status: Schema.Finite, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Unknown, + }), +}); + +const JsonString = Schema.fromJsonString(Schema.Unknown); + +const ScenarioControlSchema = Schema.Struct({ name: Schema.String }); +const ErrorControlSchema = Schema.Struct({ + method: Schema.String, + path: Schema.String, + status: Schema.Finite, + body: Schema.optional(Schema.Unknown), +}); +const ErrorAllControlSchema = Schema.Struct({ + status: Schema.Finite, + body: Schema.optional(Schema.Unknown), +}); +const RateLimitControlSchema = Schema.Struct({ + path: Schema.String, + retryAfterSeconds: Schema.Finite, +}); +const FixtureControlSchema = Schema.Struct({ key: Schema.String }); +const PgErrorControlSchema = Schema.Struct({ + code: Schema.String, + message: Schema.String, + severity: Schema.optional(Schema.String), +}); +const PgFixtureSchema = Schema.Struct({ + columns: Schema.Array(Schema.String), + typeOids: Schema.optional(Schema.Array(Schema.Finite)), + rows: Schema.Array(Schema.Array(Schema.NullOr(Schema.String))), +}); + +function encodeJson(value: unknown): Effect.Effect { + return Schema.encodeEffect(JsonString)(value); +} + function isMultipartBody(body: unknown): body is MultipartBody { return ( typeof body === "object" && @@ -161,183 +238,242 @@ interface ReplayServerOptions { port?: number; /** Optional Postgres mock server to control via /_ctrl/pg-* endpoints. */ pgMock?: PgMockHandle; + /** Explicit harness mode; avoids hidden process-environment coupling. */ + mode: "record" | "replay"; + /** Staging API base URL required for record mode. */ + stagingUrl?: string; } -export async function startReplayServer(options: ReplayServerOptions): Promise { - const isRecord = process.env["RECORD"] === "true"; - const stagingUrl = process.env["SUPABASE_STAGING_URL"]; - - if (isRecord && !stagingUrl) { - throw new Error("RECORD=true requires SUPABASE_STAGING_URL to be set"); - } - - // In record mode, wipe both fixture stores before serving any traffic. The - // recording session will repopulate only what the running tests exercise, so - // any orphan from a prior session (e.g. a scenario whose test became test.todo, - // or a recorded key the current run doesn't touch) is dropped. Replay mode is - // unaffected. - if (isRecord) { - rmSync(join(options.fixturesDir, "recorded"), { recursive: true, force: true }); - rmSync(join(options.fixturesDir, "scenarios"), { recursive: true, force: true }); - } - - const store: FixtureStore = isRecord ? new Map() : loadFixtures(options.fixturesDir); - - const counters: SequenceCounters = new Map(); - const requestLog: RecordedRequest[] = []; - const errorOverrides = new Map(); - const rateLimitOverrides = new Map(); - const recordedKeys = new Set(); - let storageProxyUrl: string | undefined; - let storageProxyAuth: string | undefined; - let dockerProxySocketPath: string | undefined; +export function startReplayServer(options: ReplayServerOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const isRecord = options.mode === "record"; + const stagingUrl = options.stagingUrl; - const scenario: ScenarioState = { name: null, queue: [], index: 0, log: [] }; - const globalErrorRef: GlobalErrorRef = { value: null }; - - function overrideKey(method: string, path: string): string { - return `${method.toUpperCase()} ${path}`; - } - - const server = Bun.serve({ - port: options.port ?? 0, - async fetch(req: Request) { - const url = new URL(req.url); - - // Control plane — not forwarded to CLI or staging - if (url.pathname.startsWith("/_ctrl/")) { - return handleControl(req, url, { - requestLog, - counters, - errorOverrides, - rateLimitOverrides, - scenario, - globalErrorRef, - isRecord, - fixturesDir: options.fixturesDir, - pgMock: options.pgMock, - }); + if (isRecord && !stagingUrl) { + return yield* Effect.die(new Error("RECORD=true requires SUPABASE_STAGING_URL to be set")); } - const method = req.method; - const pathname = url.pathname; - const query = Object.fromEntries(url.searchParams.entries()); - const requestHeaders = Object.fromEntries(req.headers.entries()); - - let requestBody: unknown = null; - let rawBody: ReadableStream | null = null; - const contentType = req.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - try { - requestBody = await req.json(); - } catch { - // not JSON — leave as null - } - } else { - rawBody = req.body; + // In record mode, wipe both fixture stores before serving any traffic. The + // recording session will repopulate only what the running tests exercise, so + // any orphan from a prior session (e.g. a scenario whose test became test.todo, + // or a recorded key the current run doesn't touch) is dropped. Replay mode is + // unaffected. + if (isRecord) { + yield* Effect.promise(() => removePath(join(options.fixturesDir, "recorded"))); + yield* Effect.promise(() => removePath(join(options.fixturesDir, "scenarios"))); } - requestLog.push({ - method, - pathname, - query, - headers: requestHeaders, - body: requestBody, - timestamp: new Date().toISOString(), + const store: FixtureStore = isRecord ? new Map() : yield* loadFixtures(options.fixturesDir); + + const counters: SequenceCounters = new Map(); + const requestLog: RecordedRequest[] = []; + const errorOverrides = new Map(); + const rateLimitOverrides = new Map(); + const recordedKeys = new Set(); + // All recording state belongs to one server execution. A single permit + // protects fixture files, the scenario log, and the shared interactions file + // from independent request keys overwriting one another. + const recordingLock = Semaphore.makeUnsafe(1); + const recordingFibers = new Set>(); + + const drainRecordingFibers = Effect.whileLoop({ + while: () => recordingFibers.size > 0, + body: () => + Effect.gen(function* () { + const fibers = [...recordingFibers]; + yield* Effect.forEach(fibers, Fiber.await, { + concurrency: "unbounded", + discard: true, + }); + yield* Effect.sync(() => { + for (const fiber of fibers) recordingFibers.delete(fiber); + }); + }), + step: () => undefined, }); + let storageProxyUrl: string | undefined; + let storageProxyAuth: string | undefined; + let dockerProxySocketPath: string | undefined; - // Global error override — returned for all API requests regardless of endpoint. - if (globalErrorRef.value) { - return Response.json(globalErrorRef.value.body, { status: globalErrorRef.value.status }); - } + const scenario: ScenarioState = { name: null, queue: [], index: 0, log: [] }; + const globalErrorRef: GlobalErrorRef = { value: null }; - // Per-endpoint error overrides - const errKey = overrideKey(method, pathname); - const errorOverride = errorOverrides.get(errKey); - if (errorOverride) { - return Response.json(errorOverride.body, { status: errorOverride.status }); + function overrideKey(method: string, path: string): string { + return `${method.toUpperCase()} ${path}`; } - const rateLimitOverride = rateLimitOverrides.get(pathname); - if (rateLimitOverride) { - return new Response(JSON.stringify({ message: "Too Many Requests" }), { - status: 429, - headers: { - "Content-Type": "application/json", - "Retry-After": String(rateLimitOverride.retryAfterSeconds), - }, - }); - } - - if (isRecord) { - return proxyAndRecord( - method, - pathname, - query, - requestHeaders, - requestBody, - rawBody, - stagingUrl!, - options.fixturesDir, - recordedKeys, - scenario, - storageProxyUrl, - storageProxyAuth, - dockerProxySocketPath, - ); - } - - // Replay mode: scenario takes priority for matching requests; out-of-band - // requests (e.g., post-command telemetry calls inserted by the Go CLI after - // every --project-ref command) fall through to the per-endpoint fixture store. - if (scenario.name !== null) { - const expected = scenario.queue[scenario.index]; - if ( - expected !== undefined && - expected.request.method.toUpperCase() === method.toUpperCase() && - expected.request.path === normalizeUrlPath(pathname) - ) { - return serveFromScenario(scenario, method, pathname, { query, body: requestBody }); - } - } - - return serveFromFixtures(store, counters, method, pathname, { query, body: requestBody }); - }, - }); + const serverContext = yield* Effect.context(); + const server = Bun.serve({ + port: options.port ?? 0, + fetch(req: Request) { + const url = new URL(req.url); + return Effect.runPromiseWith(serverContext)( + Effect.gen(function* () { + // Control plane — not forwarded to CLI or staging + if (url.pathname.startsWith("/_ctrl/")) { + return yield* Effect.promise(() => + handleControl(req, url, { + requestLog, + counters, + errorOverrides, + rateLimitOverrides, + scenario, + globalErrorRef, + isRecord, + fixturesDir: options.fixturesDir, + recordingLock, + pgMock: options.pgMock, + }), + ); + } + + const method = req.method; + const pathname = url.pathname; + const query = Object.fromEntries(url.searchParams.entries()); + const requestHeaders = Object.fromEntries(req.headers.entries()); + + let requestBody: unknown = null; + let rawBody: ReadableStream | null = null; + const contentType = req.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + requestBody = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.orElseSucceed(() => null), + ); + } else { + rawBody = req.body; + } + + const timestamp = yield* DateTime.now; + requestLog.push({ + method, + pathname, + query, + headers: requestHeaders, + body: requestBody, + timestamp: DateTime.formatIso(timestamp), + }); + + // Global error override — returned for all API requests regardless of endpoint. + if (globalErrorRef.value) { + return Response.json(globalErrorRef.value.body, { + status: globalErrorRef.value.status, + }); + } + + // Per-endpoint error overrides + const errKey = overrideKey(method, pathname); + const errorOverride = errorOverrides.get(errKey); + if (errorOverride) { + return Response.json(errorOverride.body, { status: errorOverride.status }); + } + + const rateLimitOverride = rateLimitOverrides.get(pathname); + if (rateLimitOverride) { + return Response.json( + { message: "Too Many Requests" }, + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(rateLimitOverride.retryAfterSeconds), + }, + }, + ); + } + + if (isRecord) { + if (!stagingUrl) return yield* Effect.die(new Error("Missing staging URL")); + return yield* Effect.promise(() => + proxyAndRecord( + method, + pathname, + query, + requestHeaders, + requestBody, + rawBody, + stagingUrl, + options.fixturesDir, + recordedKeys, + recordingLock, + recordingFibers, + scenario, + storageProxyUrl, + storageProxyAuth, + dockerProxySocketPath, + ), + ); + } + + // Replay mode: scenario takes priority for matching requests; out-of-band + // requests (e.g., post-command telemetry calls inserted by the Go CLI after + // every --project-ref command) fall through to the per-endpoint fixture store. + if (scenario.name !== null) { + const expected = scenario.queue[scenario.index]; + if ( + expected !== undefined && + expected.request.method.toUpperCase() === method.toUpperCase() && + expected.request.path === normalizeUrlPath(pathname) + ) { + return serveFromScenario(scenario, method, pathname, { + query, + body: requestBody, + }); + } + } + + return serveFromFixtures(store, counters, method, pathname, { + query, + body: requestBody, + }); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie), + ); + }, + }); - const port = server.port ?? 0; - const serverUrl = `http://127.0.0.1:${port}`; - - return { - url: serverUrl, - port, - stop: () => server.stop(), - getRequests: () => [...requestLog], - clearRequests: () => { - requestLog.length = 0; - resetCounters(counters); - }, - setErrorResponse: (method, path, status, body = { message: "Error" }) => { - errorOverrides.set(overrideKey(method, path), { status, body }); - }, - setRateLimit: (path, retryAfterSeconds) => { - rateLimitOverrides.set(path, { retryAfterSeconds }); - }, - clearErrorOverrides: () => { - errorOverrides.clear(); - rateLimitOverrides.clear(); - globalErrorRef.value = null; - }, - setStorageProxyUrl: (url) => { - storageProxyUrl = url; - }, - setStorageProxyAuth: (token) => { - storageProxyAuth = token; - }, - setDockerProxyUrl: (socketPath) => { - dockerProxySocketPath = socketPath; - }, - }; + const port = server.port ?? 0; + const serverUrl = `http://127.0.0.1:${port}`; + + return { + url: serverUrl, + port, + stop: () => + Effect.runPromiseWith(serverContext)( + Effect.promise(() => server.stop()).pipe(Effect.andThen(drainRecordingFibers)), + ), + getRequests: () => [...requestLog], + clearRequests: () => { + requestLog.length = 0; + resetCounters(counters); + }, + setErrorResponse: ( + method: string, + path: string, + status: number, + body: unknown = { message: "Error" }, + ) => { + errorOverrides.set(overrideKey(method, path), { status, body }); + }, + setRateLimit: (path: string, retryAfterSeconds: number) => { + rateLimitOverrides.set(path, { retryAfterSeconds }); + }, + clearErrorOverrides: () => { + errorOverrides.clear(); + rateLimitOverrides.clear(); + globalErrorRef.value = null; + }, + setStorageProxyUrl: (url: string) => { + storageProxyUrl = url; + }, + setStorageProxyAuth: (token: string) => { + storageProxyAuth = token; + }, + setDockerProxyUrl: (socketPath: string) => { + dockerProxySocketPath = socketPath; + }, + }; + }).pipe(Effect.provide(BunServices.layer), Effect.orDie), + ); } // Maximum number of recorded entries kept per endpoint key. More than this @@ -366,7 +502,7 @@ const STRIP_RESPONSE_HEADERS = new Set([ "access-control-expose-headers", ]); -async function proxyAndRecord( +function proxyAndRecord( method: string, pathname: string, query: Record, @@ -376,121 +512,143 @@ async function proxyAndRecord( stagingUrl: string, fixturesDir: string, recordedKeys: Set, + recordingLock: Semaphore.Semaphore, + recordingFibers: Set>, scenario: ScenarioState, storageProxyUrl?: string, storageProxyAuth?: string, dockerProxySocketPath?: string, ): Promise { - const isStoragePath = pathname.startsWith("/storage/v1/"); - // Docker versioned API paths start with /v1. (decimal) to distinguish from - // management API paths which start with /v1/ (slash). /_ping is the Docker - // health-check endpoint (no version prefix). - const isDockerPath = pathname.startsWith("/v1.") || pathname === "/_ping"; - - const FORWARD_HEADERS = new Set(["authorization", "content-type", "accept", "user-agent"]); - const upstreamHeaders: Record = {}; - for (const [k, v] of Object.entries(requestHeaders)) { - if (FORWARD_HEADERS.has(k.toLowerCase())) upstreamHeaders[k] = v; - } + return Effect.runPromise( + Effect.gen(function* () { + const isStoragePath = pathname.startsWith("/storage/v1/"); + // Docker versioned API paths start with /v1. (decimal) to distinguish from + // management API paths which start with /v1/ (slash). /_ping is the Docker + // health-check endpoint (no version prefix). + const isDockerPath = pathname.startsWith("/v1.") || pathname === "/_ping"; + + const FORWARD_HEADERS = new Set(["authorization", "content-type", "accept", "user-agent"]); + const upstreamHeaders: Record = {}; + for (const [k, v] of Object.entries(requestHeaders)) { + if (FORWARD_HEADERS.has(k.toLowerCase())) upstreamHeaders[k] = v; + } - if (isDockerPath && dockerProxySocketPath) { - const dockerResult = await proxyToDockerSocket( - dockerProxySocketPath, - method, - pathname, - query, - upstreamHeaders, - requestBody, - rawBody, - ); - const responseHeaders: Record = {}; - for (const [k, v] of Object.entries(dockerResult.headers)) { - if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; - } + if (isDockerPath && dockerProxySocketPath) { + const dockerResult = yield* Effect.promise(() => + proxyToDockerSocket( + dockerProxySocketPath, + method, + pathname, + query, + upstreamHeaders, + requestBody, + rawBody, + ), + ); + const responseHeaders: Record = {}; + for (const [k, v] of Object.entries(dockerResult.headers)) { + if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; + } - // Stream the response back to the caller immediately. Recording happens - // asynchronously after the body has fully drained — long-running streaming - // endpoints (image pull progress, container logs) are not blocked on it. - void recordDockerInteraction({ - bodyPromise: dockerResult.bodyPromise, - method, - pathname, - query, - requestHeaders, - requestBody, - responseStatus: dockerResult.status, - responseHeaders, - fixturesDir, - recordedKeys, - scenario, - }); + // Stream the response back to the caller immediately. Recording happens + // asynchronously after the body has fully drained — long-running streaming + // endpoints (image pull progress, container logs) are not blocked on it. + const recordingFiber = yield* Effect.forkDetach( + Effect.promise(() => + recordDockerInteraction({ + bodyPromise: dockerResult.bodyPromise, + method, + pathname, + query, + requestHeaders, + requestBody, + responseStatus: dockerResult.status, + responseHeaders, + fixturesDir, + recordedKeys, + recordingLock, + scenario, + }), + ), + ); + recordingFibers.add(recordingFiber); + recordingFiber.addObserver(() => recordingFibers.delete(recordingFiber)); - return new Response(dockerResult.stream, { - status: dockerResult.status, - headers: responseHeaders, - }); - } + return new Response(dockerResult.stream, { + status: dockerResult.status, + headers: responseHeaders, + }); + } - const targetBase = isStoragePath && storageProxyUrl ? storageProxyUrl : stagingUrl; - const targetUrl = new URL(pathname, targetBase); - for (const [k, v] of Object.entries(query)) { - targetUrl.searchParams.set(k, v); - } - if (isStoragePath && storageProxyAuth) { - upstreamHeaders["authorization"] = `Bearer ${storageProxyAuth}`; - } + const targetBase = isStoragePath && storageProxyUrl ? storageProxyUrl : stagingUrl; + const targetUrl = new URL(pathname, targetBase); + for (const [k, v] of Object.entries(query)) { + targetUrl.searchParams.set(k, v); + } + if (isStoragePath && storageProxyAuth) { + upstreamHeaders["authorization"] = `Bearer ${storageProxyAuth}`; + } - const upstreamRes = await fetch(targetUrl.toString(), { - method, - headers: upstreamHeaders, - body: - method !== "GET" && method !== "HEAD" - ? requestBody != null - ? JSON.stringify(requestBody) - : (rawBody ?? undefined) - : undefined, - }); + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(targetUrl, { headers: upstreamHeaders }); + if (method !== "GET" && method !== "HEAD") { + if (requestBody != null) { + request = yield* HttpClientRequest.bodyJson(request, requestBody); + } else if (rawBody !== null) { + const body = yield* Effect.tryPromise(() => new Response(rawBody).arrayBuffer()); + request = HttpClientRequest.bodyUint8Array( + request, + new Uint8Array(body), + upstreamHeaders["content-type"], + ); + } + } - const responseBody = await upstreamRes - .clone() - .json() - .catch(() => null); - const responseHeaders: Record = {}; - for (const [k, v] of upstreamRes.headers.entries()) { - if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; - } - const upstreamStatus = upstreamRes.status; - const responseContentType = upstreamRes.headers.get("content-type") ?? "application/json"; - - recordFixture({ - method, - pathname, - query, - requestHeaders, - requestBody, - responseStatus: upstreamStatus, - responseHeaders, - responseBody, - fixturesDir, - recordedKeys, - scenario, - }); + const upstreamRes = yield* HttpClient.execute(request); + const responseBody = yield* upstreamRes.json.pipe(Effect.orElseSucceed(() => null)); + const responseHeaders: Record = {}; + for (const [k, v] of Object.entries(upstreamRes.headers)) { + if (!STRIP_RESPONSE_HEADERS.has(k.toLowerCase())) responseHeaders[k] = v; + } + const upstreamStatus = upstreamRes.status; + const responseContentType = upstreamRes.headers["content-type"] ?? "application/json"; - return buildApiResponse( - responseBody, - upstreamStatus, - { - ...responseHeaders, - "content-type": responseContentType, - }, - projectRefFromPath(pathname), + yield* Effect.promise(() => + recordFixture({ + method, + pathname, + query, + requestHeaders, + requestBody, + responseStatus: upstreamStatus, + responseHeaders, + responseBody, + fixturesDir, + recordedKeys, + recordingLock, + scenario, + }), + ); + + return buildApiResponse( + responseBody, + upstreamStatus, + { + ...responseHeaders, + "content-type": responseContentType, + }, + projectRefFromPath(pathname), + ); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), ); } /** Record a Docker interaction once its streamed body has fully drained. Errors * are logged but do not surface — recording is best-effort and must not affect * the response the caller already received. */ -async function recordDockerInteraction(params: { +function recordDockerInteraction(params: { bodyPromise: Promise; method: string; pathname: string; @@ -501,46 +659,59 @@ async function recordDockerInteraction(params: { responseHeaders: Record; fixturesDir: string; recordedKeys: Set; + recordingLock: Semaphore.Semaphore; scenario: ScenarioState; }): Promise { - let body: Buffer; - try { - body = await params.bodyPromise; - } catch (err) { - console.error( - `[replay-server] failed to capture Docker body for ${params.method} ${params.pathname}:`, - err, - ); - return; - } - - let responseBody: unknown; - if (body.length === 0) { - responseBody = null; - } else { - try { - responseBody = JSON.parse(body.toString("utf8")); - } catch { - // Non-JSON or chunked NDJSON (image pull progress, container log frames, - // event streams) — preserve as a base64 envelope so replay can return the - // bytes verbatim instead of silently dropping them. - responseBody = { __type: "raw", base64: body.toString("base64") }; - } - } + return Effect.runPromise( + Effect.gen(function* () { + const body = yield* Effect.tryPromise(() => params.bodyPromise).pipe( + Effect.map(Option.some), + Effect.catch((err) => + Effect.logError( + `[replay-server] failed to capture Docker body for ${params.method} ${params.pathname}: ${String(err)}`, + ).pipe(Effect.as(Option.none())), + ), + ); + let responseBody: unknown; + if (Option.isNone(body)) return; + if (body.value.length === 0) { + responseBody = null; + } else { + const parsed = yield* Schema.decodeEffect(JsonString)(body.value.toString("utf8")).pipe( + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(parsed)) { + // Non-JSON or chunked NDJSON (image pull progress, container log frames, + // event streams) — preserve as a base64 envelope so replay can return the + // bytes verbatim instead of silently dropping them. + responseBody = { __type: "raw", base64: body.value.toString("base64") }; + } else { + responseBody = parsed.value; + } + } - recordFixture({ - method: params.method, - pathname: params.pathname, - query: params.query, - requestHeaders: params.requestHeaders, - requestBody: params.requestBody, - responseStatus: params.responseStatus, - responseHeaders: params.responseHeaders, - responseBody, - fixturesDir: params.fixturesDir, - recordedKeys: params.recordedKeys, - scenario: params.scenario, - }); + yield* Effect.promise(() => + recordFixture({ + method: params.method, + pathname: params.pathname, + query: params.query, + requestHeaders: params.requestHeaders, + requestBody: params.requestBody, + responseStatus: params.responseStatus, + responseHeaders: params.responseHeaders, + responseBody, + fixturesDir: params.fixturesDir, + recordedKeys: params.recordedKeys, + recordingLock: params.recordingLock, + scenario: params.scenario, + }), + ); + }).pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, Logger.layer([Logger.defaultLogger]))), + Effect.orDie, + ), + ); } function recordFixture(params: { @@ -554,67 +725,86 @@ function recordFixture(params: { responseBody: unknown; fixturesDir: string; recordedKeys: Set; + recordingLock: Semaphore.Semaphore; scenario: ScenarioState; -}): void { - const rawPair = JSON.stringify({ - request: { - method: params.method, - path: params.pathname, - query: params.query, - headers: params.requestHeaders, - body: params.requestBody, - }, - response: { - status: params.responseStatus, - headers: params.responseHeaders, - body: params.responseBody, - }, - }); - const { output } = applyPlaceholders(rawPair); - const normalized = JSON.parse(output) as { - request: FixtureRequest; - response: FixtureResponse; - }; - // Scenario interactions use unnumbered path placeholders so that comparison - // against incoming paths (normalized the same way) is always idempotent. - normalized.request.path = normalizeUrlPath(params.pathname); - +}): Promise { const key = fixtureKey(params.method, params.pathname); - const keyDir = join(params.fixturesDir, "recorded", key); - - if (!params.recordedKeys.has(key)) { - params.recordedKeys.add(key); - if (existsSync(keyDir)) { - for (const file of readdirSync(keyDir)) { - unlinkSync(join(keyDir, file)); - } - } - } - - mkdirSync(keyDir, { recursive: true }); - const nextIndex = nextFixtureIndex(keyDir); - // Cap: the matcher's `index % entries.length` wrap means more than a few - // entries adds bytes without adding coverage. Stop persisting after the cap - // is reached; the proxied response is still returned to the caller. - if (nextIndex <= MAX_FIXTURE_ENTRIES) { - const indexStr = nextIndex === 1 ? "default" : String(nextIndex); + return runFs( + params.recordingLock.withPermit( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rawPair = yield* encodeJson({ + request: { + method: params.method, + path: params.pathname, + query: params.query, + headers: params.requestHeaders, + body: params.requestBody, + }, + response: { + status: params.responseStatus, + headers: params.responseHeaders, + body: params.responseBody, + }, + }); + const { output } = applyPlaceholders(rawPair); + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(FixtureEntrySchema))( + output, + ); + const normalized: FixtureEntry = { + request: { + method: decoded.request.method, + path: decoded.request.path, + query: { ...decoded.request.query }, + headers: { ...decoded.request.headers }, + body: decoded.request.body, + }, + response: { + status: decoded.response.status, + headers: { ...decoded.response.headers }, + body: decoded.response.body, + }, + }; + normalized.request.path = normalizeUrlPath(params.pathname); + + const keyDir = join(params.fixturesDir, "recorded", key); + + if (!params.recordedKeys.has(key)) { + params.recordedKeys.add(key); + if (yield* fs.exists(keyDir)) { + const files = yield* fs.readDirectory(keyDir); + yield* Effect.forEach(files, (file) => fs.remove(join(keyDir, file)), { + discard: true, + }); + } + } - writeFileSync( - join(keyDir, `${indexStr}.request.json`), - JSON.stringify(normalized.request, null, 2), - ); - writeFileSync( - join(keyDir, `${indexStr}.response.json`), - JSON.stringify(normalized.response, null, 2), - ); - } + yield* fs.makeDirectory(keyDir, { recursive: true }); + const nextIndex = yield* nextFixtureIndex(keyDir); + if (nextIndex <= MAX_FIXTURE_ENTRIES) { + const indexStr = nextIndex === 1 ? "default" : String(nextIndex); + yield* fs.writeFileString( + join(keyDir, `${indexStr}.request.json`), + yield* encodeJson(normalized.request), + ); + yield* fs.writeFileString( + join(keyDir, `${indexStr}.response.json`), + yield* encodeJson(normalized.response), + ); + } - // If a scenario is active, also append this interaction to interactions.json. - if (params.scenario.name !== null) { - params.scenario.log.push({ request: normalized.request, response: normalized.response }); - writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); - } + if (params.scenario.name !== null) { + params.scenario.log.push({ request: normalized.request, response: normalized.response }); + yield* writeScenarioInteractions( + params.fixturesDir, + params.scenario.name, + params.scenario.log, + ); + } + }), + ), + ); } interface DockerProxyResult { @@ -636,7 +826,7 @@ interface DockerProxyResult { * connections — anything still emitting progress events stays alive. */ const DOCKER_SOCKET_IDLE_TIMEOUT_MS = 60_000; -async function proxyToDockerSocket( +function proxyToDockerSocket( socketPath: string, method: string, pathname: string, @@ -645,120 +835,115 @@ async function proxyToDockerSocket( requestBody: unknown, rawBody: ReadableStream | null, ): Promise { - const qStr = new URLSearchParams(query).toString(); - const path = qStr ? `${pathname}?${qStr}` : pathname; - - let bodyBuf: Buffer | undefined; - if (requestBody != null) { - bodyBuf = Buffer.from(JSON.stringify(requestBody), "utf8"); - } else if (rawBody != null) { - const ab = await new Response(rawBody).arrayBuffer(); - bodyBuf = Buffer.from(ab); - } - - // Strip hop-by-hop headers that must not be forwarded to the upstream socket. - const HOP_BY_HOP = new Set([ - "connection", - "transfer-encoding", - "host", - "keep-alive", - "content-length", - ]); - const reqHeaders: Record = {}; - for (const [k, v] of Object.entries(headers)) { - if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders[k] = v; - } - if (bodyBuf) { - reqHeaders["Content-Length"] = bodyBuf.length; - } - - return new Promise((resolve, reject) => { - const req = httpRequest({ socketPath, method, path, headers: reqHeaders }, (res) => { - if (res.statusCode == null) { - reject(new Error("Docker socket returned response with no status code")); - return; + const requestInit: globalThis.RequestInit = {}; + Object.defineProperty(requestInit, "unix", { + value: socketPath, + enumerable: true, + }); + return Effect.runPromise( + Effect.gen(function* () { + const qStr = new URLSearchParams(query).toString(); + const path = qStr ? `${pathname}?${qStr}` : pathname; + + // Strip hop-by-hop headers that must not be forwarded to the upstream socket. + const HOP_BY_HOP = new Set([ + "connection", + "transfer-encoding", + "host", + "keep-alive", + "content-length", + ]); + const reqHeaders: Record = {}; + for (const [k, v] of Object.entries(headers)) { + if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders[k] = v; } - const resHeaders: Record = {}; - for (const [k, v] of Object.entries(res.headers)) { - if (typeof v === "string") resHeaders[k] = v; - else if (Array.isArray(v)) resHeaders[k] = v.join(", "); + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); } - - const chunks: Buffer[] = []; - let bodyResolve: (buf: Buffer) => void = () => {}; - let bodyReject: (err: Error) => void = () => {}; - const bodyPromise = new Promise((res2, rej2) => { - bodyResolve = res2; - bodyReject = rej2; - }); - - let lastActivity = Date.now(); - const idleTimer = setInterval(() => { - if (Date.now() - lastActivity > DOCKER_SOCKET_IDLE_TIMEOUT_MS) { - clearInterval(idleTimer); - req.destroy(new Error(`Docker socket idle for ${DOCKER_SOCKET_IDLE_TIMEOUT_MS / 1000}s`)); - } - }, 5_000); - - const stream = new ReadableStream({ - start(controller) { - res.on("data", (chunk: Buffer) => { - lastActivity = Date.now(); - chunks.push(chunk); - controller.enqueue(new Uint8Array(chunk)); - }); - res.on("end", () => { - clearInterval(idleTimer); - controller.close(); - bodyResolve(Buffer.concat(chunks)); - }); - res.on("error", (err) => { - clearInterval(idleTimer); - controller.error(err); - bodyReject(err); - }); - }, - cancel(reason) { - clearInterval(idleTimer); - req.destroy(reason instanceof Error ? reason : undefined); - }, + let request = HttpClientRequest.make(method)(`http://localhost${path}`, { + headers: reqHeaders, }); + if (requestBody != null) { + request = yield* HttpClientRequest.bodyJson(request, requestBody); + } else if (rawBody !== null) { + const body = yield* Effect.tryPromise(() => new Response(rawBody).arrayBuffer()); + request = HttpClientRequest.bodyUint8Array( + request, + new Uint8Array(body), + reqHeaders["content-type"], + ); + } - resolve({ status: res.statusCode, headers: resHeaders, stream, bodyPromise }); - }); - req.on("error", (err) => { - req.destroy(); - reject(err); - }); - if (bodyBuf) req.write(bodyBuf); - req.end(); - }); + const response = yield* HttpClient.execute(request); + const responseStream = yield* Stream.toReadableStreamEffect( + response.stream.pipe( + Stream.timeoutOrElse({ + duration: Duration.millis(DOCKER_SOCKET_IDLE_TIMEOUT_MS), + orElse: () => Stream.fail(new Error("Docker socket response stream timed out")), + }), + Stream.orDie, + ), + ); + const [stream, bodyStream] = responseStream.tee(); + return { + status: response.status, + headers: { ...response.headers }, + stream, + bodyStream, + }; + }).pipe( + Effect.provide( + Layer.mergeAll( + FetchHttpClient.layer, + Layer.succeed(FetchHttpClient.RequestInit, requestInit), + ), + ), + Effect.orDie, + ), + ).then(({ bodyStream, ...result }) => ({ + ...result, + bodyPromise: Effect.runPromise( + Effect.tryPromise(() => new Response(bodyStream).arrayBuffer()).pipe( + Effect.map((body) => Buffer.from(body)), + Effect.orDie, + ), + ), + })); } function writeScenarioInteractions( fixturesDir: string, scenarioName: string, interactions: Array<{ request: FixtureRequest; response: FixtureResponse }>, -): void { - const scenarioDir = join(fixturesDir, "scenarios", scenarioName); - mkdirSync(scenarioDir, { recursive: true }); - writeFileSync(join(scenarioDir, "interactions.json"), JSON.stringify(interactions, null, 2)); +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const scenarioDir = join(fixturesDir, "scenarios", scenarioName); + yield* fs.makeDirectory(scenarioDir, { recursive: true }); + yield* fs.writeFileString( + join(scenarioDir, "interactions.json"), + yield* encodeJson(interactions), + ); + }); } -function nextFixtureIndex(keyDir: string): number { - if (!existsSync(keyDir)) return 1; - const files = readdirSync(keyDir); - let max = 0; - for (const file of files) { - const match = file.match(/^(\d+)\.(request|response)\.json$/); - if (match) { - const n = match[1] != null ? parseInt(match[1], 10) : 0; - if (n > max) max = n; +function nextFixtureIndex(keyDir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(keyDir))) return 1; + const files = yield* fs.readDirectory(keyDir); + let max = 0; + for (const file of files) { + const match = /^(\d+)\.(request|response)\.json$/.exec(file); + if (match?.[1] !== undefined) { + const n = Number.parseInt(match[1], 10); + if (n > max) max = n; + } + if (file.startsWith("default.")) max = Math.max(max, 1); } - if (file.startsWith("default.")) max = Math.max(max, 1); - } - return max + 1; + return max + 1; + }); } /** Build an API response, respecting HTTP no-body status codes (204, 304, 205). @@ -913,135 +1098,151 @@ interface ControlContext { globalErrorRef: GlobalErrorRef; isRecord: boolean; fixturesDir: string; + recordingLock: Semaphore.Semaphore; pgMock?: PgMockHandle; } -async function handleControl(req: Request, url: URL, ctx: ControlContext): Promise { - const subpath = url.pathname.slice("/_ctrl".length); - - if (subpath === "/requests") { - if (req.method === "GET") { - return Response.json(ctx.requestLog); - } - if (req.method === "DELETE") { - ctx.requestLog.length = 0; - resetCounters(ctx.counters); - return new Response(null, { status: 204 }); - } - } +function handleControl(req: Request, url: URL, ctx: ControlContext): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const subpath = url.pathname.slice("/_ctrl".length); + + if (subpath === "/requests") { + if (req.method === "GET") return Response.json(ctx.requestLog); + if (req.method === "DELETE") { + ctx.requestLog.length = 0; + resetCounters(ctx.counters); + return new Response(null, { status: 204 }); + } + } - if (subpath === "/scenario") { - if (req.method === "POST") { - const { name } = (await req.json()) as { name: string }; + if (subpath === "/scenario") { + if (req.method === "POST") { + const body = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.flatMap((value) => Schema.decodeUnknownEffect(ScenarioControlSchema)(value)), + ); + return yield* ctx.recordingLock.withPermit( + Effect.gen(function* () { + if (!ctx.isRecord) { + const interactions = yield* loadScenario( + join(ctx.fixturesDir, "scenarios"), + body.name, + ); + if (!interactions) { + return Response.json( + { message: `Missing scenario: "${body.name}" — re-record with RECORD=true` }, + { status: 404 }, + ); + } + ctx.scenario.queue = interactions; + } else { + ctx.scenario.queue = []; + ctx.scenario.log = []; + } + ctx.scenario.name = body.name; + ctx.scenario.index = 0; + return new Response(null, { status: 204 }); + }), + ); + } - if (!ctx.isRecord) { - const interactions = loadScenario(join(ctx.fixturesDir, "scenarios"), name); - if (!interactions) { - return new Response( - JSON.stringify({ - message: `Missing scenario: "${name}" — re-record with RECORD=true`, + if (req.method === "DELETE") { + return yield* ctx.recordingLock.withPermit( + Effect.gen(function* () { + if (ctx.isRecord && ctx.scenario.name !== null) { + yield* writeScenarioInteractions( + ctx.fixturesDir, + ctx.scenario.name, + ctx.scenario.log, + ); + } + ctx.scenario.name = null; + ctx.scenario.queue = []; + ctx.scenario.index = 0; + ctx.scenario.log = []; + return new Response(null, { status: 204 }); }), - { status: 404, headers: { "Content-Type": "application/json" } }, ); } - ctx.scenario.queue = interactions; - } else { - ctx.scenario.queue = []; - ctx.scenario.log = []; } - ctx.scenario.name = name; - ctx.scenario.index = 0; - return new Response(null, { status: 204 }); - } - - if (req.method === "DELETE") { - // In record mode, always flush interactions.json (even when empty) so that - // tests which trigger a global error before any API call still get a scenario file. - if (ctx.isRecord && ctx.scenario.name !== null) { - writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); + if (subpath === "/error" && req.method === "POST") { + const body = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.flatMap((value) => Schema.decodeUnknownEffect(ErrorControlSchema)(value)), + ); + ctx.errorOverrides.set(`${body.method.toUpperCase()} ${body.path}`, { + status: body.status, + body: body.body ?? { message: "Error" }, + }); + return new Response(null, { status: 204 }); } - ctx.scenario.name = null; - ctx.scenario.queue = []; - ctx.scenario.index = 0; - ctx.scenario.log = []; - return new Response(null, { status: 204 }); - } - } - - if (subpath === "/error" && req.method === "POST") { - const body = (await req.json()) as { - method: string; - path: string; - status: number; - body?: unknown; - }; - ctx.errorOverrides.set(`${body.method.toUpperCase()} ${body.path}`, { - status: body.status, - body: body.body ?? { message: "Error" }, - }); - return new Response(null, { status: 204 }); - } - if (subpath === "/error-all" && req.method === "POST") { - const body = (await req.json()) as { status: number; body?: unknown }; - ctx.globalErrorRef.value = { - status: body.status, - body: body.body ?? { message: "Error" }, - }; - return new Response(null, { status: 204 }); - } + if (subpath === "/error-all" && req.method === "POST") { + const body = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.flatMap((value) => Schema.decodeUnknownEffect(ErrorAllControlSchema)(value)), + ); + ctx.globalErrorRef.value = { + status: body.status, + body: body.body ?? { message: "Error" }, + }; + return new Response(null, { status: 204 }); + } - if (subpath === "/rate-limit" && req.method === "POST") { - const body = (await req.json()) as { - path: string; - retryAfterSeconds: number; - }; - ctx.rateLimitOverrides.set(body.path, { retryAfterSeconds: body.retryAfterSeconds }); - return new Response(null, { status: 204 }); - } + if (subpath === "/rate-limit" && req.method === "POST") { + const body = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.flatMap((value) => Schema.decodeUnknownEffect(RateLimitControlSchema)(value)), + ); + ctx.rateLimitOverrides.set(body.path, { retryAfterSeconds: body.retryAfterSeconds }); + return new Response(null, { status: 204 }); + } - if (subpath === "/overrides" && req.method === "DELETE") { - ctx.errorOverrides.clear(); - ctx.rateLimitOverrides.clear(); - ctx.globalErrorRef.value = null; - ctx.pgMock?.setState({ type: "empty" }); - return new Response(null, { status: 204 }); - } + if (subpath === "/overrides" && req.method === "DELETE") { + ctx.errorOverrides.clear(); + ctx.rateLimitOverrides.clear(); + ctx.globalErrorRef.value = null; + ctx.pgMock?.setState({ type: "empty" }); + return new Response(null, { status: 204 }); + } - if (subpath === "/pg-fixture" && req.method === "POST") { - if (!ctx.pgMock) { - return new Response(JSON.stringify({ message: "No PG mock configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); - } - const { key } = (await req.json()) as { key: string }; - const fixturePath = join(ctx.fixturesDir, "pg", `${key}.json`); - let fixture: unknown; - try { - fixture = await Bun.file(fixturePath).json(); - } catch { - return new Response(JSON.stringify({ message: `PG fixture not found: ${key}` }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); - } - ctx.pgMock.setState({ type: "fixture", fixture: fixture as PgFixture }); - return new Response(null, { status: 204 }); - } + if (subpath === "/pg-fixture" && req.method === "POST") { + if (!ctx.pgMock) + return Response.json({ message: "No PG mock configured" }, { status: 503 }); + const body = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.flatMap((value) => Schema.decodeUnknownEffect(FixtureControlSchema)(value)), + ); + const fixturePath = join(ctx.fixturesDir, "pg", `${body.key}.json`); + const fs = yield* FileSystem.FileSystem; + const fixture = yield* fs.readFileString(fixturePath).pipe( + Effect.flatMap((content) => + Schema.decodeEffect(Schema.fromJsonString(PgFixtureSchema))(content), + ), + Effect.option, + ); + if (Option.isNone(fixture)) { + return Response.json({ message: `PG fixture not found: ${body.key}` }, { status: 404 }); + } + ctx.pgMock.setState({ + type: "fixture", + fixture: { + columns: [...fixture.value.columns], + typeOids: fixture.value.typeOids ? [...fixture.value.typeOids] : undefined, + rows: fixture.value.rows.map((row) => [...row]), + }, + }); + return new Response(null, { status: 204 }); + } - if (subpath === "/pg-error" && req.method === "POST") { - if (!ctx.pgMock) { - return new Response(JSON.stringify({ message: "No PG mock configured" }), { - status: 503, - headers: { "Content-Type": "application/json" }, - }); - } - const error = (await req.json()) as { code: string; message: string; severity?: string }; - ctx.pgMock.setState({ type: "error", error }); - return new Response(null, { status: 204 }); - } + if (subpath === "/pg-error" && req.method === "POST") { + if (!ctx.pgMock) + return Response.json({ message: "No PG mock configured" }, { status: 503 }); + const error = yield* Effect.tryPromise(() => req.json()).pipe( + Effect.flatMap((value) => Schema.decodeUnknownEffect(PgErrorControlSchema)(value)), + ); + ctx.pgMock.setState({ type: "error", error }); + return new Response(null, { status: 204 }); + } - return new Response("Not Found", { status: 404 }); + return new Response("Not Found", { status: 404 }); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie), + ); } diff --git a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts index 052284a2f0..74f09d0a15 100644 --- a/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts +++ b/apps/cli-e2e/src/tests/advanced-platform-features.e2e.test.ts @@ -1,1361 +1,1908 @@ import { describe, expect } from "vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { BACKUP_TIMESTAMP, PROJECT_REF, SNIPPET_ID, isRecording } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; +const parseJson = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(input); + +const parseJsonWithData = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Struct({ data: Schema.Unknown })))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("postgres-config", () => { describe("postgres-config:get", () => { - testBehaviour("renders config overrides", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("renders config overrides", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["postgres-config", "get", "--experimental", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour( - "returns config overrides as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).not.toBeNull(); - expect(typeof parsed).toBe("object"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("returns config overrides as JSON with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "get", + "--experimental", + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(parsed).not.toBeNull(); + expect(typeof parsed).toBe("object"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "get", + "--experimental", + "--debug", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["postgres-config", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["postgres-config", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["postgres-config", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["postgres-config", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["postgres-config", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("postgres-config:update", () => { - testBehaviour("sets single config override", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("sets multiple config overrides", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--config", - "shared_buffers=256MB", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("sets single config override", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour( - "--replace-existing-overrides replaces all overrides", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--replace-existing-overrides", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, + testBehaviour("sets multiple config overrides", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--config", + "shared_buffers=256MB", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("--replace-existing-overrides replaces all overrides", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--replace-existing-overrides", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), ); testBehaviour( "--no-restart applies config without restarting postgres", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--no-restart", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 422 unrecognized config key", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "unrecognized config key: invalid_key" }, - }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "invalid_key=value", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("unrecognized config key"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "update", - "--experimental", - "--config", - "max_connections=200", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--no-restart", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 unrecognized config key", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { message: "unrecognized config key: invalid_key" }, + }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "invalid_key=value", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("unrecognized config key"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "update", + "--experimental", + "--config", + "max_connections=200", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("postgres-config:delete", () => { - testBehaviour("removes config override", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("removes multiple config keys", async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--config", - "shared_buffers", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("removes config override", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("removes multiple config keys", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--config", + "shared_buffers", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); testBehaviour( "--no-restart removes config without restarting postgres", - async ({ run, projectRef }) => { - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--no-restart", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "postgres-config", - "delete", - "--experimental", - "--config", - "max_connections", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--no-restart", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "postgres-config", + "delete", + "--experimental", + "--config", + "max_connections", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); describe("vanity-subdomains", () => { describe("vanity-subdomains:get", () => { - testBehaviour("renders vanity subdomain and status", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("renders vanity subdomain and status", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "get", "--experimental", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour( - "returns subdomain config as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("status"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("returns subdomain config as JSON with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "get", + "--experimental", + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(parsed).toHaveProperty("status"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "get", + "--experimental", + "--debug", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("vanity-subdomains:check-availability", () => { - testBehaviour("reports availability for desired subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); + testBehaviour("reports availability for desired subdomain", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour( - "returns availability as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("available"); - }, - ); - - testBehaviour("exits non-zero without --desired-subdomain flag", async ({ run }) => { - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 409 subdomain taken", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Subdomain already taken" }, - }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "taken", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("already taken"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "check-availability", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("returns availability as JSON with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(parsed).toHaveProperty("available"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero without --desired-subdomain flag", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 409 subdomain taken", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 409, + body: { message: "Subdomain already taken" }, + }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "taken", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("already taken"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "check-availability", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("vanity-subdomains:activate", () => { - testBehaviour("activates desired vanity subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Activated vanity subdomain"); - }); - - testBehaviour("exits non-zero on 409 subdomain already taken", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Subdomain already taken" }, - }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "taken", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("already taken"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "activate", - "--experimental", - "--desired-subdomain", - "myapp", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("activates desired vanity subdomain", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Activated vanity subdomain"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 409 subdomain already taken", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 409, + body: { message: "Subdomain already taken" }, + }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "taken", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("already taken"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "vanity-subdomains", + "activate", + "--experimental", + "--desired-subdomain", + "myapp", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("vanity-subdomains:delete", () => { - testBehaviour("removes vanity subdomain", async ({ run, projectRef }) => { - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Deleted vanity subdomain"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "vanity-subdomains", - "delete", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("removes vanity subdomain", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "delete", "--experimental", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Deleted vanity subdomain"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "delete", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "delete", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "delete", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["vanity-subdomains", "delete", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); describe("encryption", () => { describe("encryption:get-root-key", () => { - testBehaviour("renders root encryption key", async ({ run, projectRef }) => { - const result = await run(["encryption", "get-root-key", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }); - - testBehaviour("returns root_key as JSON with --output json", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "get-root-key", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run([ - "encryption", - "get-root-key", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders root encryption key", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("returns root_key as JSON with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "get-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("encryption:update-root-key", () => { - testBehaviour.skipIf(isRecording)("rotates the vault root key", async ({ run, projectRef }) => { - const result = await run(["encryption", "update-root-key", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("rotates the vault root key", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["encryption", "update-root-key", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["encryption", "update-root-key", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); describe("backups", () => { describe("backups:list", () => { - testBehaviour("renders backup table with REGION column", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("REGION"); - }); + testBehaviour("renders backup table with REGION column", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["backups", "list", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("REGION"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour( - "returns backup response as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "backups", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(parsed).toHaveProperty("region"); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["backups", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["backups", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("returns backup response as JSON with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["backups", "list", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(parsed).toHaveProperty("region"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["backups", "list", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["backups", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["backups", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["backups", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["backups", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["backups", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("backups:restore", () => { testBehaviour.skipIf(isRecording)( "initiates PITR restore with -t timestamp", - async ({ run, projectRef }) => { - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 422 out-of-range timestamp", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "recovery time target is out of range" }, - }), - }); - const result = await run(["backups", "restore", "-t", "0", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("out of range"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "backups", - "restore", - "-t", - String(BACKUP_TIMESTAMP), - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 out-of-range timestamp", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { message: "recovery time target is out of range" }, + }, + }), + ); + const result = yield* Effect.promise(() => + run(["backups", "restore", "-t", "0", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("out of range"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "backups", + "restore", + "-t", + String(BACKUP_TIMESTAMP), + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); describe("snippets", () => { describe("snippets:list", () => { - testBehaviour("renders snippet table with ID and NAME columns", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ID"); - expect(result.stdout).toContain("NAME"); - }); + testBehaviour("renders snippet table with ID and NAME columns", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["snippets", "list", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ID"); + expect(result.stdout).toContain("NAME"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour( - "returns snippet list as JSON with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "snippets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout); - expect(Array.isArray(parsed.data)).toBe(true); - }, - ); - - testBehaviour("--debug shows HTTP trace", async ({ run, projectRef }) => { - const result = await run(["snippets", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["snippets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("returns snippet list as JSON with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["snippets", "list", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJsonWithData(result.stdout); + expect(Array.isArray(parsed.data)).toBe(true); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("--debug shows HTTP trace", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["snippets", "list", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("snippets:download", () => { - testBehaviour.skipIf(isRecording)( - "prints SQL content to stdout", - async ({ run, projectRef }) => { - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).not.toBe(""); - }, - ); - - testBehaviour("exits non-zero on 404 snippet not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Snippet not found" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("prints SQL content to stdout", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["snippets", "download", SNIPPET_ID, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toBe(""); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 snippet not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Snippet not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["snippets", "download", SNIPPET_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/branches.e2e.test.ts b/apps/cli-e2e/src/tests/branches.e2e.test.ts index a956c1b577..27f1500499 100644 --- a/apps/cli-e2e/src/tests/branches.e2e.test.ts +++ b/apps/cli-e2e/src/tests/branches.e2e.test.ts @@ -1,418 +1,658 @@ import { describe, expect } from "vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { isRecording, PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; const BRANCH_NAME = "my-branch"; +const parseJson = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("branches", () => { describe("branches:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("STATUS"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "list", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("STATUS"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }, - ); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["branches", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["branches", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "list", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "list", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:create", () => { - testBehaviour.skipIf(isRecording)("creates ephemeral branch", async ({ run, projectRef }) => { - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour.skipIf(isRecording)("creates persistent branch", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--persistent", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour.skipIf(isRecording)("creates branch with data", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--with-data", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created preview branch:"); - }); - - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Branch name already in use" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch name already in use"); - }); - - testBehaviour("exits non-zero on 422 branching not enabled", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Preview branching is not enabled" }, - }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Preview branching is not enabled"); - }); - - testBehaviour("exits non-zero on 422 invalid region", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 422, body: { message: "Invalid region" } }), - }); - const result = await run([ - "branches", - "create", - BRANCH_NAME, - "--region", - "invalid-region", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("--region"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour.skipIf(isRecording)("creates ephemeral branch", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("creates persistent branch", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--persistent", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("creates branch with data", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--with-data", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created preview branch:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 409, body: { message: "Branch name already in use" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch name already in use"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 branching not enabled", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { message: "Preview branching is not enabled" }, + }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Preview branching is not enabled"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 invalid region", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 422, body: { message: "Invalid region" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "branches", + "create", + BRANCH_NAME, + "--region", + "invalid-region", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("--region"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "create", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:get", () => { - testBehaviour.skipIf(isRecording)( - "returns single branch details", - async ({ run, projectRef }) => { - const result = await run(["branches", "get", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(BRANCH_NAME); - }, + testBehaviour.skipIf(isRecording)("returns single branch details", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "get", BRANCH_NAME, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(BRANCH_NAME); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "get", - BRANCH_NAME, - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as Record; - expect(parsed).toMatchObject({ SUPABASE_JWT_SECRET: expect.any(String) }); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "get", "nonexistent", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "get", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "branches", + "get", + BRANCH_NAME, + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(parsed).toMatchObject({ SUPABASE_JWT_SECRET: expect.any(String) }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Branch not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "get", "nonexistent", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "get", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:update", () => { - testBehaviour.skipIf(isRecording)("renames branch with --name", async ({ run, projectRef }) => { - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--name", - "renamed-branch", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Updated preview branch:"); - }); + testBehaviour.skipIf(isRecording)("renames branch with --name", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "branches", + "update", + BRANCH_NAME, + "--name", + "renamed-branch", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Updated preview branch:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); testBehaviour.skipIf(isRecording)( "changes git branch with --git-branch", - async ({ run, projectRef }) => { - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--git-branch", - "feature/new", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Updated preview branch:"); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run([ - "branches", - "update", - "nonexistent", - "--name", - "new-name", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "branches", - "update", - BRANCH_NAME, - "--name", - "new-name", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "branches", + "update", + BRANCH_NAME, + "--git-branch", + "feature/new", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Updated preview branch:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Branch not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "branches", + "update", + "nonexistent", + "--name", + "new-name", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "branches", + "update", + BRANCH_NAME, + "--name", + "new-name", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:pause", () => { - testBehaviour.skipIf(isRecording)("pauses branch successfully", async ({ run, projectRef }) => { - const result = await run(["branches", "pause", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "pause", "nonexistent", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "pause", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("pauses branch successfully", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "pause", BRANCH_NAME, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Branch not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "pause", "nonexistent", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "pause", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:unpause", () => { - testBehaviour.skipIf(isRecording)( - "unpauses branch successfully", - async ({ run, projectRef }) => { - const result = await run(["branches", "unpause", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "unpause", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("unpauses branch successfully", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "unpause", BRANCH_NAME, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "unpause", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:delete", () => { - testBehaviour.skipIf(isRecording)( - "deletes branch successfully", - async ({ run, projectRef }) => { - const result = await run(["branches", "delete", BRANCH_NAME, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Deleted preview branch:"); - }, - ); - - testBehaviour("exits non-zero on 404 branch not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Branch not found" } }), - }); - const result = await run(["branches", "delete", "nonexistent", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Branch not found"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "delete", BRANCH_NAME, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("deletes branch successfully", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "delete", BRANCH_NAME, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Deleted preview branch:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 branch not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Branch not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "delete", "nonexistent", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Branch not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "delete", BRANCH_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("branches:disable", () => { - testBehaviour.skipIf(isRecording)("disables preview branching", async ({ run, projectRef }) => { - const result = await run(["branches", "disable", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Disabled preview branching for project:"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["branches", "disable", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour.skipIf(isRecording)("disables preview branching", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["branches", "disable", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Disabled preview branching for project:"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["branches", "disable", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/config-push.e2e.test.ts b/apps/cli-e2e/src/tests/config-push.e2e.test.ts index c4d7abc20c..3931aac72c 100644 --- a/apps/cli-e2e/src/tests/config-push.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-push.e2e.test.ts @@ -1,19 +1,49 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + /** * Write a supabase/config.toml covering every section the Go updater touches. * For each section, it'll create a small diff to the recorded test project. * Without any diff, no PATCH/POST requests will be sent to the management API. */ -function writeConfigToml(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), - ` +const writeConfigToml = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + ` project_id = "test-project" [api] @@ -52,154 +82,227 @@ file_size_limit = "50MiB" [experimental.webhooks] enabled = true `.trimStart(), - ); -} + ); + }); /** * The CLI will prompt the user y/n for each section that has a diff. * The test process runs with stdin closed, so run the commands with the `--yes` flag. */ describe("config push", () => { - testBehaviour("reconciles every section against the remote", async ({ run, workspace }) => { - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("reconciles every section against the remote", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("emits HTTP trace with --debug", async ({ run, workspace }) => { - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF, "--debug"]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); + testBehaviour("emits HTTP trace with --debug", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF, "--debug"]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401 with token guidance", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 401, - body: { message: "Invalid token" }, - }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("401"); - }); + testBehaviour("exits non-zero on 401 with token guidance", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 401, + body: { message: "Invalid token" }, + }, + }), + ); - testBehaviour( - "exits non-zero on 403 with resource context", - async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 403, - body: { - message: `Forbidden: you do not have access to project ${PROJECT_REF}`, - }, - }), - }); + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("401"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403 with resource context", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 403, + body: { + message: `Forbidden: you do not have access to project ${PROJECT_REF}`, + }, + }, + }), + ); - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("403"); - }, + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("403"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour("exits non-zero on 404", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 404, - body: { message: "Project not found" }, - }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("404"); - }); + testBehaviour("exits non-zero on 404", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 404, + body: { message: "Project not found" }, + }, + }), + ); - testBehaviour("exits non-zero on 409", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Conflict" } }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("409"); - }); + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("404"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 422 with field detail", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { - message: "Invalid config: max_rows must be a positive integer", - }, - }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("422"); - expect(result.stderr).toContain("max_rows"); - }); + testBehaviour("exits non-zero on 409", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 409, body: { message: "Conflict" } }, + }), + ); - testBehaviour("exits non-zero on 429 after retrying", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 429, - body: { message: "Too Many Requests" }, - }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("429"); - }); + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("409"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 500, - body: { message: "Internal Server Error" }, - }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("500"); - }); + testBehaviour("exits non-zero on 422 with field detail", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { + message: "Invalid config: max_rows must be a positive integer", + }, + }, + }), + ); - testBehaviour("exits non-zero on 502", async ({ run, apiUrl, workspace }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 502, body: { message: "Bad Gateway" } }), - }); - - writeConfigToml(workspace.path); - const result = await run(["config", "push", "--yes", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("502"); - }); + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("422"); + expect(result.stderr).toContain("max_rows"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429 after retrying", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 429, + body: { message: "Too Many Requests" }, + }, + }), + ); + + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("429"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 500, + body: { message: "Internal Server Error" }, + }, + }), + ); + + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("500"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 502", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 502, body: { message: "Bad Gateway" } }, + }), + ); + + yield* writeConfigToml(workspace.path); + const result = yield* Effect.promise(() => + run(["config", "push", "--yes", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("502"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index 26730ea3de..8828648c6a 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -1,9 +1,12 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + // CLI-1489: v2.99.0 introduced a TypeScript config loader in the Bun shell // that strictly decoded supabase/config.toml through an Effect schema. Any // non-string field written as env(VAR) — e.g. a port — was rejected before @@ -16,25 +19,28 @@ import { testBehaviour } from "./test-context.ts"; // and get the injected error. Either way we only assert that the CLI got // past config decode. -function writeConfigWithEnvPorts(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), - [ - 'project_id = "with-env-ports"', - "", - "[api]", - 'port = "env(SUPABASE_API_PORT)"', - "", - "[db]", - 'port = "env(SUPABASE_DB_PORT)"', - "", - "[analytics]", - 'port = "env(SUPABASE_ANALYTICS_PORT)"', - "", - ].join("\n"), - ); -} +const writeConfigWithEnvPorts = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + [ + 'project_id = "with-env-ports"', + "", + "[api]", + 'port = "env(SUPABASE_API_PORT)"', + "", + "[db]", + 'port = "env(SUPABASE_DB_PORT)"', + "", + "[analytics]", + 'port = "env(SUPABASE_ANALYTICS_PORT)"', + "", + ].join("\n"), + ); + }); const ENV_PORTS = { SUPABASE_API_PORT: "54321", @@ -43,21 +49,26 @@ const ENV_PORTS = { }; describe("env-in-config-toml", () => { - testBehaviour("does not crash on numeric fields", async ({ run, workspace, apiUrl }) => { - writeConfigWithEnvPorts(workspace.path); - - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF], { - env: ENV_PORTS, - }); - - const output = `${result.stdout}\n${result.stderr}`; - expect(output).not.toContain("ProjectConfigParseError"); - expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); - }); + testBehaviour("does not crash on numeric fields", ({ run, workspace, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* writeConfigWithEnvPorts(workspace.path); + const request = yield* HttpClientRequest.make("POST")(apiUrl + "/_ctrl/error-all").pipe( + HttpClientRequest.bodyJson({ + status: 401, + body: { message: "Invalid token" }, + }), + ); + yield* HttpClient.execute(request); + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", PROJECT_REF], { + env: ENV_PORTS, + }), + ); + const output = result.stdout + "\n" + result.stderr; + expect(output).not.toContain("ProjectConfigParseError"); + expect(output).not.toMatch(/Expected number.*env\(SUPABASE_/); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index dd07f0b650..6af8271b33 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -1,8 +1,46 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { testBehaviour } from "./test-context.ts"; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + +const parseJsonArray = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Unknown)))(input); + +const QueryOutputSchema = Schema.Union([ + Schema.Array(Schema.Unknown), + Schema.Struct({ rows: Schema.Array(Schema.Unknown) }), +]); + +const parseQueryOutput = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(QueryOutputSchema))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + // --------------------------------------------------------------------------- // Workspace helpers // --------------------------------------------------------------------------- @@ -16,25 +54,34 @@ import { testBehaviour } from "./test-context.ts"; * CLI takes the pooler path instead. Combined with SUPABASE_DB_PASSWORD (set in * harness.ts), ParseDatabaseConfig succeeds without any network call, so the * command reaches its RunE and makes the Management API call under test. */ -function linkProject(dir: string, ref: string): void { - const tempDir = join(dir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), ref); - writeFileSync( - join(tempDir, "pooler-url"), - `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, - ); -} +const linkProject = (dir: string, ref: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = path.join(dir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), ref); + yield* fs.writeFileString( + path.join(tempDir, "pooler-url"), + `postgresql://postgres.${ref}:[YOUR-PASSWORD]@aws-0-eu-central-1.pooler.supabase.com:6543/postgres`, + ); + }); const TEST_MIGRATION_SQL = "CREATE TABLE IF NOT EXISTS e2e_test_table (id bigint generated always as identity primary key);"; /** Create a single migration file in supabase/migrations/ and return the SQL. */ -function seedMigration(dir: string): void { - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_e2e_test.sql"), TEST_MIGRATION_SQL); -} +const seedMigration = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_e2e_test.sql"), + TEST_MIGRATION_SQL, + ); + }); // --------------------------------------------------------------------------- // db advisors @@ -42,100 +89,147 @@ function seedMigration(dir: string): void { describe("db advisors", () => { describe("db advisors:security", () => { - testBehaviour("returns security advisors", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe(""); - expect(result.stderr).toContain("No issues found"); - }); + testBehaviour("returns security advisors", ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "security"]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe(""); + expect(result.stderr).toContain("No issues found"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); testBehaviour( "exits zero when --fail-on error and no error-level advisors found", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run([ - "db", - "advisors", - "--linked", - "--type", - "security", - "--fail-on", - "error", - ]); - expect(result.exitCode).toBe(0); - }, + ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "security", "--fail-on", "error"]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour("exits non-zero on 401", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["db", "advisors", "--linked", "--type", "security"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 401", ({ run, projectRef, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "security"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, projectRef, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "security"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, projectRef, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "security"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, projectRef, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "security"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("db advisors:performance", () => { - testBehaviour("returns performance advisors", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "performance"]); - expect(result.exitCode).toBe(0); - if (result.stdout.trim()) { - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - } else { - expect(result.stderr).toContain("No issues found"); - } - }); + testBehaviour("returns performance advisors", ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "performance"]), + ); + expect(result.exitCode).toBe(0); + if (result.stdout.trim()) { + const parsed = yield* parseJsonArray(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + } else { + expect(result.stderr).toContain("No issues found"); + } + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("db advisors:all", () => { - testBehaviour("returns advisors with --type all", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "advisors", "--linked", "--type", "all"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("returns advisors with --type all", ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => + run(["db", "advisors", "--linked", "--type", "all"]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); }); @@ -145,51 +239,70 @@ describe("db advisors", () => { describe("db query", () => { describe("db query:linked", () => { - testBehaviour( - "returns SELECT 1 result in table format", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "--linked", "SELECT 1"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("1"); - }, + testBehaviour("returns SELECT 1 result in table format", ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => run(["db", "query", "--linked", "SELECT 1"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour("returns JSON with --output json", async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "query", "--linked", "--output", "json", "SELECT 1"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown; - // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. - // In normal mode it's a plain array. - const rows = Array.isArray(parsed) ? parsed : (parsed as { rows: unknown[] }).rows; - expect(Array.isArray(rows)).toBe(true); - expect(rows).toHaveLength(1); - }); - - testBehaviour("exits non-zero on 401", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["db", "query", "--linked", "SELECT 1"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, projectRef, apiUrl, workspace }) => { - linkProject(workspace.path, projectRef); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["db", "query", "--linked", "SELECT 1"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("returns JSON with --output json", ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => + run(["db", "query", "--linked", "--output", "json", "SELECT 1"]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseQueryOutput(result.stdout); + // In agent mode (CLAUDECODE env set) the output is wrapped in {warning, boundary, rows}. + // In normal mode it's a plain array. + const rows = "rows" in parsed ? parsed.rows : parsed; + expect(Array.isArray(rows)).toBe(true); + expect(rows).toHaveLength(1); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, projectRef, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => run(["db", "query", "--linked", "SELECT 1"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, projectRef, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => run(["db", "query", "--linked", "SELECT 1"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); }); @@ -201,33 +314,43 @@ describe("db push", () => { describe("db push:dry-run", () => { testBehaviour( "exits non-zero on connection refused with --dry-run", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - seedMigration(workspace.path); - const result = await run(["db", "push", "--dry-run"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }, + ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + yield* seedMigration(workspace.path); + const result = yield* Effect.promise(() => run(["db", "push", "--dry-run"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); }); describe("db push:local", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "push", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused with --local", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["db", "push", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("db push:linked", () => { testBehaviour( "exits non-zero on connection refused with --linked", - async ({ run, projectRef, workspace }) => { - linkProject(workspace.path, projectRef); - const result = await run(["db", "push"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }, + ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* linkProject(workspace.path, projectRef); + const result = yield* Effect.promise(() => run(["db", "push"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); }); }); @@ -237,11 +360,15 @@ describe("db push", () => { // --------------------------------------------------------------------------- describe("db pull", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "pull", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused with --local", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["db", "pull", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -249,11 +376,15 @@ describe("db pull", () => { // --------------------------------------------------------------------------- describe("db lint", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "lint", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused with --local", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["db", "lint", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -261,17 +392,27 @@ describe("db lint", () => { // --------------------------------------------------------------------------- describe("db dump", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "dump", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused with --local", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["db", "dump", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero when --role-only and --data-only are both set", async ({ run }) => { - const result = await run(["db", "dump", "--local", "--role-only", "--data-only"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); - }); + testBehaviour("exits non-zero when --role-only and --data-only are both set", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["db", "dump", "--local", "--role-only", "--data-only"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toLowerCase()).toMatch(/role.only|data.only|mutually exclusive/); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -279,11 +420,15 @@ describe("db dump", () => { // --------------------------------------------------------------------------- describe("db reset", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["db", "reset", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/connect|not running/i); - }); + testBehaviour("exits non-zero on connection refused with --local", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["db", "reset", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/connect|not running/i); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -291,20 +436,30 @@ describe("db reset", () => { // --------------------------------------------------------------------------- describe("test new", () => { - testBehaviour("creates a pgTAP test file", async ({ run, workspace }) => { - const result = await run(["test", "new", "my_test"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/my_test/); - const files = readdirSync(join(workspace.path, "supabase", "tests")).filter((f) => - f.endsWith("my_test_test.sql"), - ); - expect(files.length).toBe(1); - }); + testBehaviour("creates a pgTAP test file", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["test", "new", "my_test"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/my_test/); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files = (yield* fs.readDirectory( + path.join(workspace.path, "supabase", "tests"), + )).filter((f) => f.endsWith("my_test_test.sql")); + expect(files.length).toBe(1); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero when name argument is missing", async ({ run }) => { - const result = await run(["test", "new"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero when name argument is missing", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["test", "new"])); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -312,9 +467,13 @@ describe("test new", () => { // --------------------------------------------------------------------------- describe("test db", () => { - testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { - const result = await run(["test", "db", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("connect"); - }); + testBehaviour("exits non-zero on connection refused with --local", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["test", "db", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("connect"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index 2cc7ad8133..e16186a042 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -1,264 +1,438 @@ import { describe, expect } from "vitest"; -import { testBehaviour } from "./test-context"; -import { isRecording, PROJECT_REF } from "./env"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; +import { testBehaviour } from "./test-context.ts"; +import { isRecording, PROJECT_REF } from "./env.ts"; const CONFIGURED_CNAME = "www.urgsimurksi.xyz"; +const parseJson = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("domains", () => { describe.todo("domains:create — requires mocking of 1.1.1.1 for DNS queries"); describe("domains:get", () => { - testBehaviour("custom domain disabled", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 400, - body: { message: "Please enable custom domains first" }, - }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", - ); - }); - - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("www.urgsimurksi.xyz CNAME -> __PROJECT_REF__.supabase.red"); - }); - - testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run([ - "domains", - "get", - "--project-ref", - PROJECT_REF, - "--output", - "json", - ]); - - expect(result.exitCode).toBe(0); - - expect(JSON.parse(result.stdout)).toEqual({ - custom_hostname: "www.urgsimurksi.xyz", - data: { - errors: [], - messages: [], - result: { - custom_origin_server: "__PROJECT_REF__.supabase.red", - hostname: "www.urgsimurksi.xyz", - id: "00000000-0000-0000-0000-000000000000", - ownership_verification: { - name: "", - type: "", - value: "", - }, - ssl: { - status: "active", - validation_records: [], + testBehaviour("custom domain disabled", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 400, + body: { message: "Please enable custom domains first" }, + }, + }), + ); + + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("no custom domain", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", + ); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "www.urgsimurksi.xyz CNAME -> __PROJECT_REF__.supabase.red", + ); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("domain activated", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF, "--output", "json"]), + ); + + expect(result.exitCode).toBe(0); + + expect(yield* parseJson(result.stdout)).toEqual({ + custom_hostname: "www.urgsimurksi.xyz", + data: { + errors: [], + messages: [], + result: { + custom_origin_server: "__PROJECT_REF__.supabase.red", + hostname: "www.urgsimurksi.xyz", + id: "00000000-0000-0000-0000-000000000000", + ownership_verification: { + name: "", + type: "", + value: "", + }, + ssl: { + status: "active", + validation_records: [], + }, + status: "active", + }, + success: true, }, - status: "active", - }, - success: true, - }, - status: "5_services_reconfigured", - }); - }); - - testBehaviour("exists non-zero on 403", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 403, - body: { message: "Unauthorized" }, - }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("403"); - }); - - testBehaviour("exists non-zero on 401", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 401, - body: { message: "Unauthorized" }, - }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("401"); - }); - - testBehaviour("exists non-zero on 429", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 429, - body: { message: "Too Many Requests" }, - }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("429"); - }); - - testBehaviour("exists non-zero on 500", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 500, - body: { message: "Internal Server Error" }, - }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("500"); - }); - - testBehaviour("exists non-zero on 502", async ({ apiUrl, run }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 502, - body: { message: "Bad Gateway" }, - }), - }); - - const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("502"); - }); + status: "5_services_reconfigured", + }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exists non-zero on 403", ({ apiUrl, run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 403, + body: { message: "Unauthorized" }, + }, + }), + ); + + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("403"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exists non-zero on 401", ({ apiUrl, run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 401, + body: { message: "Unauthorized" }, + }, + }), + ); + + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("401"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exists non-zero on 429", ({ apiUrl, run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 429, + body: { message: "Too Many Requests" }, + }, + }), + ); + + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("429"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exists non-zero on 500", ({ apiUrl, run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 500, + body: { message: "Internal Server Error" }, + }, + }), + ); + + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("500"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exists non-zero on 502", ({ apiUrl, run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 502, + body: { message: "Bad Gateway" }, + }, + }), + ); + + const result = yield* Effect.promise(() => + run(["domains", "get", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("502"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("domains:reverify", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( - "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", - ); - }); - - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "reverify", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + testBehaviour("custom domain disabled", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "reverify", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("no custom domain", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "reverify", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "reverify", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "_acme-challenge.www.urgsimurksi.xyz TXT -> dx8wOwXMeAgc7uOQ3q0RlSQKvGl_HhcIsph_9PqwQYw", + ); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "reverify", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("domains:activate", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification in debug mode", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF, "--debug"]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toMatch(/HTTP.*POST:/); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("verification completed", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain(`completed`); - expect(result.stderr).toContain(`at ${CONFIGURED_CNAME}`); - }); - - testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { - const result = await run(["domains", "activate", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); + testBehaviour("custom domain disabled", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "activate", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("no custom domain", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "activate", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "activate", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification in debug mode", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "activate", "--project-ref", PROJECT_REF, "--debug"]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toMatch(/HTTP.*POST:/); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("verification completed", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "activate", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(`completed`); + expect(result.stderr).toContain(`at ${CONFIGURED_CNAME}`); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("domain activated", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "activate", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("domains:delete", () => { - testBehaviour("custom domain disabled", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("no custom domain", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("400"); - }); - - testBehaviour.skipIf(isRecording)("pending verification", async ({ run }) => { - const result = await run(["domains", "delete", "--project-ref", PROJECT_REF]); - - expect(result.exitCode).toBe(0); - }); + testBehaviour("custom domain disabled", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "delete", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("no custom domain", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "delete", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("400"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour.skipIf(isRecording)("pending verification", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["domains", "delete", "--project-ref", PROJECT_REF]), + ); + + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 50f70ddbc4..f4ddbd20ab 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,45 +1,45 @@ import type { CLITarget } from "@supabase/cli-test-helpers"; +import { Config, ConfigProvider, Effect, Option, Schema } from "effect"; type CliE2eMode = "replay" | "record"; +const environmentLayer = ConfigProvider.layer(ConfigProvider.fromEnv()); +export const readEnv = (name: string): string | undefined => + Option.getOrUndefined( + Effect.runSync(Config.option(Config.string(name)).pipe(Effect.provide(environmentLayer))), + ); + // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to // staging and captures fixtures. // Back-compat: RECORD=true still maps to `record`. +const decodeMode = Schema.decodeUnknownSync(Schema.Literals(["replay", "record"])); const MODE: CliE2eMode = - (process.env["CLI_E2E_MODE"] as CliE2eMode | undefined) ?? - (process.env["RECORD"] === "true" ? "record" : "replay"); + readEnv("CLI_E2E_MODE") === undefined + ? readEnv("RECORD") === "true" + ? "record" + : "replay" + : decodeMode(readEnv("CLI_E2E_MODE")); export const isRecording = MODE === "record"; -// The replay server + tests/setup.ts key recording off the RECORD env var -// directly. Keep RECORD in sync with MODE in BOTH directions so an explicit -// CLI_E2E_MODE wins over a stale RECORD env — e.g. CLI_E2E_MODE=replay must NOT -// record and wipe fixtures just because RECORD=true lingers in the shell. -if (isRecording) { - process.env["RECORD"] = "true"; -} else { - delete process.env["RECORD"]; -} - -// startReplayServer + tests/setup.ts read SUPABASE_STAGING_URL directly as the -// record proxy target. Normalise it from CLI_E2E_API_URL so -// `CLI_E2E_MODE=record CLI_E2E_API_URL=…` works without also setting the legacy var. -if (isRecording && !process.env["SUPABASE_STAGING_URL"] && process.env["CLI_E2E_API_URL"]) { - process.env["SUPABASE_STAGING_URL"] = process.env["CLI_E2E_API_URL"]; -} +// Base Management API URL for record mode (the real API). Replay mode never reads this. +export const TARGET_API_URL = + readEnv("CLI_E2E_API_URL") ?? readEnv("SUPABASE_STAGING_URL") ?? "https://api.supabase.green"; -// In replay mode the token never reaches a real API, but the CLI validates the -// format before making any request (must match sbp_[a-f0-9]{40}). In record mode -// it must be a valid token for the staging API. +// In replay mode the token never reaches a real API, but the Go CLI validates +// the format before making any request (must match sbp_[a-f0-9]{40}). +// In record mode it must be a valid token for the staging API. export const ACCESS_TOKEN = - process.env["SUPABASE_ACCESS_TOKEN"] ?? "sbp_0000000000000000000000000000000000000000"; + readEnv("SUPABASE_ACCESS_TOKEN") ?? + readEnv("SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN") ?? + "sbp_0000000000000000000000000000000000000000"; // Which target to run. Defaults to "ts-legacy" — the only shipped CLI shell and -// therefore the authoritative target for replay and recording. Validated +// therefore the authoritative target for both recording and live tests. Validated // eagerly so a stale value (e.g. the retired "go" target) fails with a clear error // instead of an undefined-command crash inside the harness. const VALID_TARGETS: ReadonlyArray = ["ts-legacy", "ts-next"]; -const rawTarget = process.env["CLI_HARNESS_TARGET"] ?? "ts-legacy"; +const rawTarget = readEnv("CLI_HARNESS_TARGET") ?? "ts-legacy"; const matchedTarget = VALID_TARGETS.find((target) => target === rawTarget); if (matchedTarget === undefined) { throw new Error( @@ -50,32 +50,32 @@ if (matchedTarget === undefined) { export const TARGET = matchedTarget; // Region for the fresh recording project. -export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; +export const REGION = readEnv("CLI_E2E_REGION") ?? "us-east-1"; // In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__ // in the fixture key. In record mode supply a real project ref via env. -export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? "aaaaaaaaaaaaaaaaaaaa"; +export const PROJECT_REF = readEnv("SUPABASE_TEST_PROJECT_REF") ?? "aaaaaaaaaaaaaaaaaaaa"; // In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__. // In record mode supply a real org slug via env, or let the resolver derive it. -export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? "bbbbbbbbbbbbbbbbbbbb"; +export const ORG_ID = readEnv("SUPABASE_TEST_ORG_ID") ?? "bbbbbbbbbbbbbbbbbbbb"; // UUID of an existing SAML provider on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real provider ID via env. export const PROVIDER_ID = - process.env["SUPABASE_TEST_PROVIDER_ID"] ?? "00000000-0000-0000-0000-000000000000"; + readEnv("SUPABASE_TEST_PROVIDER_ID") ?? "00000000-0000-0000-0000-000000000000"; // UUID of an existing SQL snippet on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real snippet UUID via env. export const SNIPPET_ID = - process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-0000-0000-000000000001"; + readEnv("SUPABASE_TEST_SNIPPET_ID") ?? "00000000-0000-0000-0000-000000000001"; // Unix epoch seconds for a PITR restore timestamp within the staging project's backup window. // In replay mode the replay server serves responses in order regardless of the request body value. // In record mode supply a real timestamp (within the backup window) via env. export const BACKUP_TIMESTAMP = parseInt( - process.env["SUPABASE_TEST_BACKUP_TIMESTAMP"] ?? "1707407047", + readEnv("SUPABASE_TEST_BACKUP_TIMESTAMP") ?? "1707407047", 10, ); diff --git a/apps/cli-e2e/src/tests/functions.e2e.test.ts b/apps/cli-e2e/src/tests/functions.e2e.test.ts index f39a423806..6025741408 100644 --- a/apps/cli-e2e/src/tests/functions.e2e.test.ts +++ b/apps/cli-e2e/src/tests/functions.e2e.test.ts @@ -1,363 +1,480 @@ import { describe, expect } from "vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { isRecording, PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; const FUNCTION_NAME = "hello-world"; +const parseJsonArray = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Unknown)))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("functions", () => { describe("functions:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("STATUS"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["functions", "list", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("STATUS"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "functions", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }); + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["functions", "list", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJsonArray(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["functions", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); + testBehaviour("includes debug output with --debug", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["functions", "list", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["functions", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("functions:deploy", () => { // Deploy requires the Go binary to bundle function files locally before any API call, // so error injection tests pre-create the function with `functions new` first. - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => run(["functions", "new", FUNCTION_NAME])); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "deploy", FUNCTION_NAME, "--use-api", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => run(["functions", "new", FUNCTION_NAME])); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "deploy", FUNCTION_NAME, "--use-api", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/rate-limit`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - path: `/v1/projects/${PROJECT_REF}/functions/deploy`, - retryAfterSeconds: 0, - }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => run(["functions", "new", FUNCTION_NAME])); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/rate-limit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + path: `/v1/projects/${PROJECT_REF}/functions/deploy`, + retryAfterSeconds: 0, + }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "deploy", FUNCTION_NAME, "--use-api", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await run(["functions", "new", FUNCTION_NAME]); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "deploy", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => run(["functions", "new", FUNCTION_NAME])); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "deploy", FUNCTION_NAME, "--use-api", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("functions:delete", () => { - testBehaviour.skipIf(isRecording)( - "deletes function successfully", - async ({ run, projectRef }) => { - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted Function"); - }, + testBehaviour.skipIf(isRecording)("deletes function successfully", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["functions", "delete", FUNCTION_NAME, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted Function"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), ); - testBehaviour("exits non-zero on 404 function not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Function not found" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("does not exist"); - }); + testBehaviour("exits non-zero on 404 function not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Function not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "delete", FUNCTION_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("does not exist"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "delete", FUNCTION_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "delete", FUNCTION_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "delete", FUNCTION_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "delete", - FUNCTION_NAME, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["functions", "delete", FUNCTION_NAME, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("functions:download", () => { - testBehaviour.skipIf(isRecording)( - "downloads function successfully", - async ({ run, projectRef }) => { - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Downloaded Function"); - }, + testBehaviour.skipIf(isRecording)("downloads function successfully", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["functions", "download", FUNCTION_NAME, "--use-api", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Downloaded Function"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 404 function not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Function not found" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Function not found"); - }); + testBehaviour("exits non-zero on 404 function not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Function not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Function not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "functions", - "download", - FUNCTION_NAME, - "--use-api", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "functions", + "download", + FUNCTION_NAME, + "--use-api", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("functions:new", () => { - testBehaviour("successfully creates a new function", async ({ run }) => { - const result = await run(["functions", "new", "testFunction"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created new Function at supabase/functions/testFunction"); - }); + testBehaviour("successfully creates a new function", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["functions", "new", "testFunction"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Created new Function at supabase/functions/testFunction", + ); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/gen.e2e.test.ts b/apps/cli-e2e/src/tests/gen.e2e.test.ts index 5362a5bf12..4a8878c6f6 100644 --- a/apps/cli-e2e/src/tests/gen.e2e.test.ts +++ b/apps/cli-e2e/src/tests/gen.e2e.test.ts @@ -1,152 +1,238 @@ import { describe, expect } from "vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { isRecording, PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; -function decodeJwtPart(part: string): Record { +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + +const JsonRecord = Schema.Record(Schema.String, Schema.Unknown); + +function decodeJwtPart(part: string) { const padded = part + "=".repeat((4 - (part.length % 4)) % 4); - return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record; + return Schema.decodeEffect(Schema.fromJsonString(JsonRecord))( + Buffer.from(padded, "base64").toString("utf8"), + ); } describe("gen", () => { describe("gen:types", () => { testBehaviour.skipIf(isRecording)( "generates typescript types from project", - async ({ run, projectRef }) => { - // #5212 — with CLAUDECODE=1, piped/redirected output must not include the - // plugin hint (would break `gen types > file.ts` and similar captures). - const result = await run(["gen", "types", "--project-id", projectRef], { - env: { CLAUDECODE: "1" }, - }); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("export type Json"); - expect(result.stdout).toContain("export type Database"); - expect(result.stdout).not.toMatch(/claude-code-hint/); - expect(result.stderr).not.toMatch(/claude-code-hint/); - }, + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + // #5212 — with CLAUDECODE=1, piped/redirected output must not include the + // plugin hint (would break `gen types > file.ts` and similar captures). + const result = yield* Effect.promise(() => + run(["gen", "types", "--project-id", projectRef], { + env: { CLAUDECODE: "1" }, + }), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("export type Json"); + expect(result.stdout).toContain("export type Database"); + expect(result.stdout).not.toMatch(/claude-code-hint/); + expect(result.stderr).not.toMatch(/claude-code-hint/); + }), + ), ); - testBehaviour.skipIf(isRecording)( - "includes debug output with --debug", - async ({ run, projectRef }) => { - const result = await run(["gen", "types", "--debug", "--project-id", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }, + testBehaviour.skipIf(isRecording)("includes debug output with --debug", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["gen", "types", "--debug", "--project-id", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }), + ), ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["gen", "types", "--project-id", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero with no data source specified", async ({ runNoProjectId }) => { - const result = await runNoProjectId(["gen", "types"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["gen", "types", "--project-id", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["gen", "types", "--project-id", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["gen", "types", "--project-id", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }), + ), + ); + + testBehaviour("exits non-zero with no data source specified", ({ runNoProjectId }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => runNoProjectId(["gen", "types"])); + expect(result.exitCode).not.toBe(0); + }), + ), + ); }); describe("gen:signing-key", () => { - testBehaviour("generates ES256 signing key by default", async ({ run }) => { - const result = await run(["gen", "signing-key"]); - expect(result.exitCode).toBe(0); - const key = JSON.parse(result.stdout) as Record; - expect(key["kty"]).toBe("EC"); - expect(key["alg"]).toBe("ES256"); - expect(key["crv"]).toBe("P-256"); - expect(key["use"]).toBe("sig"); - expect(typeof key["d"]).toBe("string"); - expect((key["d"] as string).length).toBeGreaterThan(0); - }); - - testBehaviour("generates RS256 signing key with --algorithm RS256", async ({ run }) => { - const result = await run(["gen", "signing-key", "--algorithm", "RS256"]); - expect(result.exitCode).toBe(0); - const key = JSON.parse(result.stdout) as Record; - expect(key["kty"]).toBe("RSA"); - expect(key["alg"]).toBe("RS256"); - expect(key["use"]).toBe("sig"); - expect(typeof key["n"]).toBe("string"); - expect((key["n"] as string).length).toBeGreaterThan(0); - }); + testBehaviour("generates ES256 signing key by default", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["gen", "signing-key"])); + expect(result.exitCode).toBe(0); + const key = yield* Schema.decodeEffect(Schema.fromJsonString(JsonRecord))(result.stdout); + expect(key["kty"]).toBe("EC"); + expect(key["alg"]).toBe("ES256"); + expect(key["crv"]).toBe("P-256"); + expect(key["use"]).toBe("sig"); + expect(typeof key["d"]).toBe("string"); + expect((key["d"] as string).length).toBeGreaterThan(0); + }), + ), + ); + + testBehaviour("generates RS256 signing key with --algorithm RS256", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["gen", "signing-key", "--algorithm", "RS256"]), + ); + expect(result.exitCode).toBe(0); + const key = yield* Schema.decodeEffect(Schema.fromJsonString(JsonRecord))(result.stdout); + expect(key["kty"]).toBe("RSA"); + expect(key["alg"]).toBe("RS256"); + expect(key["use"]).toBe("sig"); + expect(typeof key["n"]).toBe("string"); + expect((key["n"] as string).length).toBeGreaterThan(0); + }), + ), + ); }); describe("gen:bearer-jwt", () => { - testBehaviour("exits non-zero without --role", async ({ run }) => { - const result = await run(["gen", "bearer-jwt"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('"role"'); - }); - - testBehaviour("generates bearer jwt for anon role", async ({ run }) => { - const result = await run(["gen", "bearer-jwt", "--role", "anon"]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const header = decodeJwtPart(parts[0]!); - expect(header["alg"]).toBe("ES256"); - expect(typeof header["kid"]).toBe("string"); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("anon"); - expect(typeof payload["exp"]).toBe("number"); - expect(typeof payload["iat"]).toBe("number"); - }); - - testBehaviour("generates bearer jwt with custom validity", async ({ run }) => { - const result = await run(["gen", "bearer-jwt", "--role", "anon", "--valid-for", "1h"]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("anon"); - expect((payload["exp"] as number) - (payload["iat"] as number)).toBe(3600); - }); - - testBehaviour( - "generates bearer jwt for authenticated role with custom sub", - async ({ run }) => { - const result = await run([ - "gen", - "bearer-jwt", - "--role", - "authenticated", - "--sub", - "user-123", - ]); - expect(result.exitCode).toBe(0); - const parts = result.stdout.trim().split("."); - expect(parts).toHaveLength(3); - const payload = decodeJwtPart(parts[1]!); - expect(payload["role"]).toBe("authenticated"); - expect(payload["sub"]).toBe("user-123"); - }, + testBehaviour("exits non-zero without --role", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["gen", "bearer-jwt"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('"role"'); + }), + ), + ); + + testBehaviour("generates bearer jwt for anon role", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["gen", "bearer-jwt", "--role", "anon"])); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const header = yield* decodeJwtPart(parts[0]!); + expect(header["alg"]).toBe("ES256"); + expect(typeof header["kid"]).toBe("string"); + const payload = yield* decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("anon"); + expect(typeof payload["exp"]).toBe("number"); + expect(typeof payload["iat"]).toBe("number"); + }), + ), + ); + + testBehaviour("generates bearer jwt with custom validity", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["gen", "bearer-jwt", "--role", "anon", "--valid-for", "1h"]), + ); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const payload = yield* decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("anon"); + expect((payload["exp"] as number) - (payload["iat"] as number)).toBe(3600); + }), + ), + ); + + testBehaviour("generates bearer jwt for authenticated role with custom sub", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["gen", "bearer-jwt", "--role", "authenticated", "--sub", "user-123"]), + ); + expect(result.exitCode).toBe(0); + const parts = result.stdout.trim().split("."); + expect(parts).toHaveLength(3); + const payload = yield* decodeJwtPart(parts[1]!); + expect(payload["role"]).toBe("authenticated"); + expect(payload["sub"]).toBe("user-123"); + }), + ), ); }); }); diff --git a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts index 9e03edf7f5..f2a3bcf549 100644 --- a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts +++ b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts @@ -1,7 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { readEnv } from "./env.ts"; // CLI-1970 shrank the bundled `supabase-go` binary down to exactly the // commands the TypeScript CLI's `LegacyGoProxy` can spawn — every other Go @@ -19,7 +19,8 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest"; // `SUPABASE_GO_BINARY` is only set to a freshly built binary in CI (see // `.github/workflows/test.yml`); locally this whole suite no-ops so a // developer without a Go toolchain/build isn't forced to fail it. -const GO_BINARY = process.env["SUPABASE_GO_BINARY"]; +const GO_BINARY = readEnv("SUPABASE_GO_BINARY"); +const testLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", () => { const binary = GO_BINARY as string; @@ -27,44 +28,57 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( let workspaceDir: string; let bogusProfilePath: string; - beforeAll(() => { - workspaceDir = mkdtempSync(join(tmpdir(), "cli-e2e-go-binary-surface-")); + beforeAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + workspaceDir = yield* fs.makeTempDirectory({ prefix: "cli-e2e-go-binary-surface-" }); - // `cmd/root.go`'s `checkUpgrade` hits the real GitHub releases API on - // every invocation that returns a nil error (e.g. every `--help` call - // below), unless a `supabase/.temp/cli-latest` cache file already exists - // relative to the spawn's cwd and is less than 10h old (`shouldFetchRelease`). - // Pre-seeding it here keeps this whole suite hermetic instead of quietly - // depending on network access. - mkdirSync(join(workspaceDir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workspaceDir, "supabase", ".temp", "cli-latest"), "v0.0.0"); + // `cmd/root.go`'s `checkUpgrade` hits the real GitHub releases API on + // every invocation that returns a nil error (e.g. every `--help` call + // below), unless a `supabase/.temp/cli-latest` cache file already exists + // relative to the spawn's cwd and is less than 10h old (`shouldFetchRelease`). + // Pre-seeding it here keeps this whole suite hermetic instead of quietly + // depending on network access. + yield* fs.makeDirectory(path.join(workspaceDir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspaceDir, "supabase", ".temp", "cli-latest"), + "v0.0.0", + ); - // A bogus `--profile` for the two Management-API-gated delegates (`gen - // keys`, `functions download --legacy-bundle`). The unique profile name - // guarantees an OS-keyring credential from a real `supabase login` can - // never match it, and the unreachable api_url means even a stray token - // match still fails at connect instead of reaching a real API. - bogusProfilePath = join(workspaceDir, "profile.yaml"); - writeFileSync( - bogusProfilePath, - [ - "name: cli-e2e-go-binary-surface-guard", - 'api_url: "http://127.0.0.1:1"', - 'dashboard_url: "http://127.0.0.1:1"', - "project_host: localhost", - ].join("\n"), - ); - }); + // A bogus `--profile` for the two Management-API-gated delegates (`gen + // keys`, `functions download --legacy-bundle`). The unique profile name + // guarantees an OS-keyring credential from a real `supabase login` can + // never match it, and the unreachable api_url means even a stray token + // match still fails at connect instead of reaching a real API. + bogusProfilePath = path.join(workspaceDir, "profile.yaml"); + yield* fs.writeFileString( + bogusProfilePath, + [ + "name: cli-e2e-go-binary-surface-guard", + 'api_url: "http://127.0.0.1:1"', + 'dashboard_url: "http://127.0.0.1:1"', + "project_host: localhost", + ].join("\n"), + ); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - afterAll(() => { - rmSync(workspaceDir, { recursive: true, force: true }); - }); + afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(workspaceDir, { recursive: true }); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); function runGo(args: ReadonlyArray, envOverrides: Record = {}) { const result = Bun.spawnSync([binary, ...args], { cwd: workspaceDir, env: { - PATH: process.env["PATH"] ?? "", HOME: workspaceDir, SUPABASE_HOME: workspaceDir, // Belt-and-braces: no command exercised here should ever reach a diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index 7c59917e3e..dfd3c913b3 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -1,28 +1,37 @@ -import { mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { testBehaviour } from "./test-context.ts"; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function setupInspectWorkspace(dir: string, pgPort: number): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), - ['project_id = "test-project"', "", "[db]", `port = ${pgPort}`].join("\n"), - ); -} +const setupInspectWorkspace = (dir: string, pgPort: number) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "port = " + pgPort].join("\n"), + ); + }); -async function setPgFixture(apiUrl: string, key: string): Promise { - const res = await fetch(`${apiUrl}/_ctrl/pg-fixture`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), +const setPgFixture = (apiUrl: string, key: string) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.make("POST")(apiUrl + "/_ctrl/pg-fixture").pipe( + HttpClientRequest.bodyJson({ key }), + ); + const response = yield* HttpClient.execute(request); + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text; + return yield* Effect.die(new Error('Failed to set PG fixture "' + key + '": ' + body)); + } }); - if (!res.ok) throw new Error(`Failed to set PG fixture "${key}": ${await res.text()}`); -} // --------------------------------------------------------------------------- // Subcommand table @@ -49,70 +58,97 @@ const SUBCOMMANDS = [ // --------------------------------------------------------------------------- describe("inspect:flags", () => { - testBehaviour("rejects --db-url with --local", async ({ run, workspace, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - const result = await run([ - "inspect", - "db", - "db-stats", - "--db-url", - "postgresql://postgres:postgres@localhost:5432/postgres", - "--local", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("rejects --db-url with --local", ({ run, workspace, pgMockPort }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + const result = yield* Effect.promise(() => + run([ + "inspect", + "db", + "db-stats", + "--db-url", + "postgresql://postgres:postgres@localhost:5432/postgres", + "--local", + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); for (const { name, fixtureKey, assertValue } of SUBCOMMANDS) { - describe(`inspect:db:${name}`, () => { - testBehaviour( - "renders query results as a table", - async ({ run, workspace, apiUrl, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - await setPgFixture(apiUrl, fixtureKey); - const result = await run(["inspect", "db", name, "--local"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain(assertValue); - }, + describe("inspect:db:" + name, () => { + testBehaviour("renders query results as a table", ({ run, workspace, apiUrl, pgMockPort }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + yield* setPgFixture(apiUrl, fixtureKey); + const result = yield* Effect.promise(() => run(["inspect", "db", name, "--local"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(assertValue); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "inspect", - "db", - name, - "--db-url", - "postgresql://postgres:postgres@localhost:1/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "inspect", + "db", + name, + "--db-url", + "postgresql://postgres:postgres@localhost:1/postgres", + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + ), + ); }); } describe("inspect:report", () => { - testBehaviour("saves CSV files on success", async ({ run, workspace, pgMockPort }) => { - setupInspectWorkspace(workspace.path, pgMockPort); - const outDir = join(workspace.path, "report-out"); - const result = await run(["inspect", "report", "--local", "--output-dir", outDir]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Reports saved to"); - const subdirs = readdirSync(outDir); - expect(subdirs.length).toBe(1); - const dateDir = subdirs[0]!; - const csvFiles = readdirSync(join(outDir, dateDir)); - expect(csvFiles.length).toBeGreaterThan(0); - expect(csvFiles.every((f) => f.endsWith(".csv"))).toBe(true); - }); + testBehaviour("saves CSV files on success", ({ run, workspace, pgMockPort }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupInspectWorkspace(workspace.path, pgMockPort); + const path = yield* Path.Path; + const outDir = path.join(workspace.path, "report-out"); + const result = yield* Effect.promise(() => + run(["inspect", "report", "--local", "--output-dir", outDir]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("Reports saved to"); + const fs = yield* FileSystem.FileSystem; + const subdirs = yield* fs.readDirectory(outDir); + expect(subdirs.length).toBe(1); + const dateDir = subdirs[0]!; + const csvFiles = yield* fs.readDirectory(path.join(outDir, dateDir)); + expect(csvFiles.length).toBeGreaterThan(0); + expect(csvFiles.every((f) => f.endsWith(".csv"))).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "inspect", - "report", - "--db-url", - "postgresql://postgres:postgres@localhost:1/postgres", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "inspect", + "report", + "--db-url", + "postgresql://postgres:postgres@localhost:1/postgres", + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + ), + ); }); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index c92dbe0058..737c6f323c 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -1,98 +1,142 @@ -import { readdirSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { testBehaviour } from "./test-context.ts"; const MIGRATION_NAME = "my_change"; +const testLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); describe("migrations", () => { describe("migration:new", () => { - testBehaviour("creates timestamped sql file", async ({ run, workspace }) => { - const result = await run(["migration", "new", MIGRATION_NAME]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created new migration at"); - const files = readdirSync(join(workspace.path, "supabase", "migrations")); - expect(files.some((f) => f.endsWith(`_${MIGRATION_NAME}.sql`))).toBe(true); - }); + testBehaviour("creates timestamped sql file", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "new", MIGRATION_NAME])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created new migration at"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const files = yield* fs.readDirectory( + path.join(workspace.path, "supabase", "migrations"), + ); + expect(files.some((f) => f.endsWith(`_${MIGRATION_NAME}.sql`))).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero without name argument", async ({ run }) => { - const result = await run(["migration", "new"]); - expect(result.exitCode).not.toBe(0); - // CLI-1901: a missing positional argument's usage block now prints to - // stderr (never stdout) instead of being duplicated across both. - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("migration name"); - }); + testBehaviour("exits non-zero without name argument", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "new"])); + expect(result.exitCode).not.toBe(0); + // CLI-1901: a missing positional argument's usage block now prints to + // stderr (never stdout) instead of being duplicated across both. + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("migration name"); + }), + ), + ); }); describe("migration:list", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "list", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "list", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + ), + ); }); describe("migration:up", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "up", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "up", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + ), + ); }); describe("migration:down", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "down", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "down", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + ), + ); - testBehaviour("exits non-zero on connection refused with --last 2", async ({ run }) => { - const result = await run(["migration", "down", "--last", "2", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused with --last 2", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["migration", "down", "--last", "2", "--local"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + ), + ); }); describe("migration:repair", () => { - testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { - const result = await run(["migration", "repair", "--local", "20230101000000"]); - expect(result.exitCode).not.toBe(0); - // CLI-1901: a missing required flag now drops the vendored library's - // duplicate usage dump entirely (Go's cobra suppresses usage for this - // case too), leaving only this repo's existing Go-parity error line, - // which spells the flag name without its `--` prefix. - expect(result.stderr).toContain('"status" not set'); - }); + testBehaviour("exits non-zero when --status flag is missing", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["migration", "repair", "--local", "20230101000000"]), + ); + expect(result.exitCode).not.toBe(0); + // CLI-1901: a missing required flag now drops the vendored library's + // duplicate usage dump entirely (Go's cobra suppresses usage for this + // case too), leaving only this repo's existing Go-parity error line, + // which spells the flag name without its `--` prefix. + expect(result.stderr).toContain('"status" not set'); + }), + ), + ); - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run([ - "migration", - "repair", - "--status", - "applied", - "--local", - "20230101000000", - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["migration", "repair", "--status", "applied", "--local", "20230101000000"]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + ), + ); }); describe("migration:squash", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "squash", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).not.toBe(""); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "squash", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).not.toBe(""); + }), + ), + ); }); describe("migration:fetch", () => { - testBehaviour("exits non-zero on connection refused", async ({ run }) => { - const result = await run(["migration", "fetch", "--local"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to connect"); - }); + testBehaviour("exits non-zero on connection refused", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["migration", "fetch", "--local"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to connect"); + }), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/network-security.e2e.test.ts b/apps/cli-e2e/src/tests/network-security.e2e.test.ts index 8cff53bcc4..f71509e013 100644 --- a/apps/cli-e2e/src/tests/network-security.e2e.test.ts +++ b/apps/cli-e2e/src/tests/network-security.e2e.test.ts @@ -1,710 +1,931 @@ import { describe, expect } from "vitest"; +import { Effect } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("network-bans", () => { describe("network-bans:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "get", - "--experimental", - "--debug", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-bans", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-bans", + "get", + "--experimental", + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-bans", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("network-bans:remove", () => { - testBehaviour("removes IP from ban list", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("removes multiple IPs from ban list", async ({ run, projectRef }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--db-unban-ip", - "5.6.7.8", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with invalid IP address", async ({ run }) => { - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "invalid-ip", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("invalid IP address"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-bans", - "remove", - "--experimental", - "--db-unban-ip", - "1.2.3.4", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); + testBehaviour("removes IP from ban list", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("removes multiple IPs from ban list", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--db-unban-ip", + "5.6.7.8", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero with invalid IP address", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "invalid-ip", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("invalid IP address"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "network-bans", + "remove", + "--experimental", + "--db-unban-ip", + "1.2.3.4", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); describe("network-restrictions", () => { describe("network-restrictions:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-restrictions", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["network-restrictions", "get", "--experimental", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "get", + "--experimental", + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-restrictions", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-restrictions", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-restrictions", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-restrictions", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["network-restrictions", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("network-restrictions:update", () => { - testBehaviour("sets CIDR allowlist", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("appends to existing restrictions", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--append", - "--db-allow-cidr", - "8.8.8.0/24", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("bypasses CIDR validation checks", async ({ run, projectRef }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--bypass-cidr-checks", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with invalid CIDR format", async ({ run }) => { - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "not-a-cidr", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("failed to parse IP"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "network-restrictions", - "update", - "--experimental", - "--db-allow-cidr", - "0.0.0.0/0", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("sets CIDR allowlist", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("appends to existing restrictions", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--append", + "--db-allow-cidr", + "8.8.8.0/24", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("bypasses CIDR validation checks", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--bypass-cidr-checks", + "--db-allow-cidr", + "0.0.0.0/0", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero with invalid CIDR format", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "not-a-cidr", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("failed to parse IP"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "network-restrictions", + "update", + "--experimental", + "--db-allow-cidr", + "0.0.0.0/0", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); describe("ssl-enforcement", () => { describe("ssl-enforcement:get", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "ssl-enforcement", - "get", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "get", "--experimental", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "get", + "--experimental", + "--output", + "json", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "get", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("ssl-enforcement:update", () => { - testBehaviour("enables SSL enforcement", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("disables SSL enforcement", async ({ run, projectRef }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--disable-db-ssl-enforcement", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero with mutually exclusive flags", async ({ run }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--disable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero with no flags provided", async ({ run }) => { - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Project not found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "ssl-enforcement", - "update", - "--experimental", - "--enable-db-ssl-enforcement", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("enables SSL enforcement", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("disables SSL enforcement", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--disable-db-ssl-enforcement", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero with mutually exclusive flags", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--disable-db-ssl-enforcement", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero with no flags provided", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["ssl-enforcement", "update", "--experimental", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Project not found"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "ssl-enforcement", + "update", + "--experimental", + "--enable-db-ssl-enforcement", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/orgs.e2e.test.ts b/apps/cli-e2e/src/tests/orgs.e2e.test.ts index 8335185225..45c9db250c 100644 --- a/apps/cli-e2e/src/tests/orgs.e2e.test.ts +++ b/apps/cli-e2e/src/tests/orgs.e2e.test.ts @@ -1,157 +1,266 @@ import { describe, expect } from "vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { isRecording } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; const ORG_NAME = "My Test Org"; +const parseJsonArray = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Unknown)))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("orgs", () => { describe("orgs:list", () => { - testBehaviour("renders org data", async ({ run }) => { - const result = await run(["orgs", "list"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("ID"); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); - }); - - testBehaviour("returns json output with --output json", async ({ run }) => { - const result = await run(["orgs", "list", "--output", "json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0]).toMatchObject({ id: expect.any(String), name: expect.any(String) }); - }); - - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["orgs", "list", "--debug"]); - expect(result.exitCode).toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["orgs", "list"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders org data", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["orgs", "list"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ID"); + expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["orgs", "list", "--output", "json"])); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJsonArray(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toMatchObject({ id: expect.any(String), name: expect.any(String) }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("includes debug output with --debug", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["orgs", "list", "--debug"])); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "list"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "list"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "list"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "list"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("orgs:create", () => { - testBehaviour.skipIf(isRecording)("creates organization with name", async ({ run }) => { - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Created organization:"); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); - }); - - testBehaviour("exits non-zero without name in non-TTY", async ({ run }) => { - const result = await run(["orgs", "create"]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 409, - body: { message: "Organization name already in use" }, - }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Organization name already in use"); - }); - - testBehaviour("exits non-zero on 403 plan limit reached", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Organization limit reached" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Organization limit reached"); - }); - - testBehaviour("exits non-zero on 422 validation error", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 422, body: { message: "Invalid organization name" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid organization name"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["orgs", "create", ORG_NAME]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("creates organization with name", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created organization:"); + expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero without name in non-TTY", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["orgs", "create"])); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 409, + body: { message: "Organization name already in use" }, + }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Organization name already in use"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403 plan limit reached", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Organization limit reached" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Organization limit reached"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 validation error", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 422, body: { message: "Invalid organization name" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid organization name"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => run(["orgs", "create", ORG_NAME])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index 0d875a1c43..e5e80c7e79 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -1,63 +1,130 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect, inject, test } from "vitest"; import { createHarness, exec, makeTempDir } from "@supabase/cli-test-helpers"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { ACCESS_TOKEN, isRecording, PROJECT_REF, TARGET } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; -describe("init", () => { - testBehaviour("creates supabase/config.toml and exits zero", async ({ run, workspace }) => { - const result = await run(["init"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - expect(existsSync(join(workspace.path, "supabase", "config.toml"))).toBe(true); +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + +const writeExistingConfig = (workspacePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspacePath, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspacePath, "supabase", "config.toml"), + "# existing config\n", + ); + }); + +const fileExists = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(filePath); }); +describe("init", () => { + testBehaviour("creates supabase/config.toml and exits zero", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["init"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + expect( + yield* fileExists((yield* Path.Path).join(workspace.path, "supabase", "config.toml")), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + testBehaviour( "exits non-zero if config.toml already exists without --force", - async ({ run, workspace }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync(join(workspace.path, "supabase", "config.toml"), "# existing config\n"); - const result = await run(["init"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("force"); - }, + ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* writeExistingConfig(workspace.path); + const result = yield* Effect.promise(() => run(["init"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("force"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour("exits zero with --force when config.toml exists", async ({ run, workspace }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync(join(workspace.path, "supabase", "config.toml"), "# existing config\n"); - const result = await run(["init", "--force"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - expect(existsSync(join(workspace.path, "supabase", "config.toml"))).toBe(true); - }); + testBehaviour("exits zero with --force when config.toml exists", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + yield* writeExistingConfig(workspace.path); + const result = yield* Effect.promise(() => run(["init", "--force"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + expect( + yield* fileExists((yield* Path.Path).join(workspace.path, "supabase", "config.toml")), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour( - "creates VS Code settings with --with-vscode-settings", - async ({ run, workspace }) => { - const result = await run(["init", "--with-vscode-settings"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Generated VS Code settings"); - expect(existsSync(join(workspace.path, ".vscode", "settings.json"))).toBe(true); - }, + testBehaviour("creates VS Code settings with --with-vscode-settings", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["init", "--with-vscode-settings"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated VS Code settings"); + expect( + yield* fileExists((yield* Path.Path).join(workspace.path, ".vscode", "settings.json")), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour( - "creates IntelliJ settings with --with-intellij-settings", - async ({ run, workspace }) => { - const result = await run(["init", "--with-intellij-settings"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Generated IntelliJ settings"); - expect(existsSync(join(workspace.path, ".idea", "deno.xml"))).toBe(true); - }, + testBehaviour("creates IntelliJ settings with --with-intellij-settings", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["init", "--with-intellij-settings"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Generated IntelliJ settings"); + expect( + yield* fileExists((yield* Path.Path).join(workspace.path, ".idea", "deno.xml")), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["init", "--debug"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase init"); - }); + testBehaviour("includes debug output with --debug", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["init", "--debug"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase init"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("link", () => { @@ -65,91 +132,140 @@ describe("link", () => { // injects SUPABASE_PROJECT_ID, which the new Go CLI accepts as a substitute for // --project-ref in non-TTY mode, bypassing the required-flag check. A raw test // lets us omit projectId so the CLI correctly requires the --project-ref flag. - test("exits non-zero without --project-ref in non-TTY", async () => { - const serverUrl = inject("replayServerUrl") as string; - const dir = makeTempDir("cli-e2e-link-no-ref-"); - using _ = dir; - const harness = createHarness(TARGET, { - apiUrl: serverUrl, - accessToken: ACCESS_TOKEN, - cwd: dir.path, - }); - const result = await exec(harness, ["link"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("project-ref"); - }); + test("exits non-zero without --project-ref in non-TTY", () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const serverUrl = yield* Schema.decodeEffect(Schema.String)(inject("replayServerUrl")); + const dir = yield* Effect.acquireRelease( + Effect.promise(() => makeTempDir("cli-e2e-link-no-ref-")), + (temp) => Effect.promise(() => temp[Symbol.asyncDispose]()), + ); + const harness = createHarness(TARGET, { + apiUrl: serverUrl, + accessToken: ACCESS_TOKEN, + cwd: dir.path, + }); + const result = yield* Effect.promise(() => exec(harness, ["link"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("project-ref"); + }), + ), + )); // The testBehaviour run fixture always injects SUPABASE_PROJECT_ID, which the // new Go CLI accepts in place of --project-ref, bypassing the required-flag // check. Link therefore proceeds to the API and succeeds. - testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", async ({ run }) => { - const result = await run(["link"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["link"])); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => run(["link", "--project-ref", PROJECT_REF])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => run(["link", "--project-ref", PROJECT_REF])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["link", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => run(["link", "--project-ref", PROJECT_REF])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); // link makes concurrent Management API calls after the initial project-status and // api-keys calls. The concurrent service calls fail silently (non-fatal). Only // the first two sequential calls need fixture entries. testBehaviour.skipIf(isRecording)( "links project successfully", - async ({ run, projectRef, workspace }) => { - const result = await run(["link", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase link"); - expect(existsSync(join(workspace.path, "supabase", ".temp", "project-ref"))).toBe(true); - }, + ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["link", "--project-ref", projectRef])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect( + yield* fileExists( + (yield* Path.Path).join(workspace.path, "supabase", ".temp", "project-ref"), + ), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); testBehaviour.skipIf(isRecording)( "--skip-pooler uses direct connection", - async ({ run, projectRef, workspace }) => { - const result = await run(["link", "--project-ref", projectRef, "--skip-pooler"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished supabase link"); - expect(existsSync(join(workspace.path, "supabase", ".temp", "project-ref"))).toBe(true); - }, + ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["link", "--project-ref", projectRef, "--skip-pooler"]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished supabase link"); + expect( + yield* fileExists( + (yield* Path.Path).join(workspace.path, "supabase", ".temp", "project-ref"), + ), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); }); describe("unlink", () => { - testBehaviour("exits non-zero when project not linked", async ({ run }) => { - const result = await run(["unlink"]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("supabase link"); - }); + testBehaviour("exits non-zero when project not linked", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["unlink"])); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("supabase link"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); // The success path (pre-populate project-ref → unlink succeeds) is omitted: the // unlink handler deletes the database-password keyring entry on success. On diff --git a/apps/cli-e2e/src/tests/projects.e2e.test.ts b/apps/cli-e2e/src/tests/projects.e2e.test.ts index 8efc73aa8e..5c915cde5f 100644 --- a/apps/cli-e2e/src/tests/projects.e2e.test.ts +++ b/apps/cli-e2e/src/tests/projects.e2e.test.ts @@ -1,312 +1,497 @@ import { describe, expect } from "vitest"; +import { Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { isRecording, PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; +const parseJson = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(input); + +const parseJsonArray = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Unknown)))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + describe("projects", () => { describe("projects:list", () => { - testBehaviour("renders project list", async ({ run }) => { - const result = await run(["projects", "list"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); - expect(result.stdout).toContain("REFERENCE ID"); - }); + testBehaviour("renders project list", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["projects", "list"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toContain("REFERENCE ID"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => run(["projects", "list"])); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("returns json output with --output json", async ({ run }) => { - const result = await run(["projects", "list", "--output", "json"]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed.length).toBeGreaterThan(0); - expect(parsed[0]).toMatchObject({ name: expect.any(String), ref: expect.any(String) }); - }); + testBehaviour("returns json output with --output json", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["projects", "list", "--output", "json"])); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJsonArray(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + expect(parsed[0]).toMatchObject({ name: expect.any(String), ref: expect.any(String) }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("includes debug output with --debug", async ({ run }) => { - const result = await run(["projects", "list", "--debug"]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("includes debug output with --debug", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["projects", "list", "--debug"])); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => run(["projects", "list"])); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => run(["projects", "list"])); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["projects", "list"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => run(["projects", "list"])); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("projects:api-keys", () => { - testBehaviour("shows default and anon keys", async ({ run, projectRef }) => { - const result = await run(["projects", "api-keys", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("KEY VALUE"); - expect(result.stdout).toContain("anon"); - expect(result.stdout).toContain("default"); - }); + testBehaviour("shows default and anon keys", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["projects", "api-keys", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("KEY VALUE"); + expect(result.stdout).toContain("anon"); + expect(result.stdout).toContain("default"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "projects", - "api-keys", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ name: "anon" })])); - }); + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["projects", "api-keys", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJson(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "anon" })]), + ); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "api-keys", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "api-keys", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["projects", "api-keys", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "api-keys", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("projects:create", () => { - testBehaviour("creates project with required flags", async ({ run, orgId }) => { - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - orgId, - "--db-password", - "password123", - "--region", - "us-east-1", - "--size", - "micro", - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("REFERENCE ID"); - }); + testBehaviour("creates project with required flags", ({ run, orgId }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "projects", + "create", + "my-project", + "--org-id", + orgId, + "--db-password", + "password123", + "--region", + "us-east-1", + "--size", + "micro", + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("REFERENCE ID"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero without required flags in non-TTY", async ({ run }) => { - const result = await run(["projects", "create", "--org-id", "test-org-id"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero without required flags in non-TTY", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["projects", "create", "--org-id", "test-org-id"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 409 name conflict", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 409, body: { message: "Project name already in use" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 409 name conflict", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 409, body: { message: "Project name already in use" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 422 validation error", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { - message: "Validation failed", - errors: [{ field: "region", message: "Invalid region" }], - }, - }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 422 validation error", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { + message: "Validation failed", + errors: [{ field: "region", message: "Invalid region" }], + }, + }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 403 no org access", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 403 no org access", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "projects", - "create", - "my-project", - "--org-id", - "test-org-id", - "--db-password", - "password123", - "--region", - "us-east-1", - ]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "projects", + "create", + "my-project", + "--org-id", + "test-org-id", + "--db-password", + "password123", + "--region", + "us-east-1", + ]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); describe("projects:delete", () => { testBehaviour.skipIf(isRecording)( "returns 400 when project not ready for deletion", - async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 400, - body: { message: "Project not ready for deletion." }, - }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }, + ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 400, + body: { message: "Project not ready for deletion." }, + }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "delete", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), ); - testBehaviour.skipIf(isRecording)( - "deletes project with --yes flag", - async ({ run, projectRef }) => { - const result = await run(["projects", "delete", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Deleted project"); - }, + testBehaviour.skipIf(isRecording)("deletes project with --yes flag", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["projects", "delete", projectRef, "--yes"]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Deleted project"); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), ); - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "delete", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "delete", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["projects", "delete", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["projects", "delete", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/secrets.e2e.test.ts b/apps/cli-e2e/src/tests/secrets.e2e.test.ts index 9ace9e4e12..9ade02bc2c 100644 --- a/apps/cli-e2e/src/tests/secrets.e2e.test.ts +++ b/apps/cli-e2e/src/tests/secrets.e2e.test.ts @@ -1,215 +1,348 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + +const parseJsonArray = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Unknown)))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + +const writeSecretsEnvFile = (workspacePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(workspacePath, ".env.local"), "FOO=bar\n"); + }); + describe("secrets", () => { describe("secrets:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("NAME"); - expect(result.stdout).toContain("DIGEST"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run([ - "secrets", - "list", - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - const parsed = JSON.parse(result.stdout) as unknown[]; - expect(Array.isArray(parsed)).toBe(true); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["secrets", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["secrets", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("NAME"); + expect(result.stdout).toContain("DIGEST"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "list", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + const parsed = yield* parseJsonArray(result.stdout); + expect(Array.isArray(parsed)).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "list", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("secrets:set", () => { - testBehaviour("sets a single secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("sets multiple secrets", async ({ run, projectRef }) => { - const result = await run([ - "secrets", - "set", - "FOO=bar", - "BAZ=qux", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("sets secrets from env file", async ({ run, workspace, projectRef }) => { - writeFileSync(join(workspace.path, ".env.local"), "FOO=bar\n"); - const result = await run([ - "secrets", - "set", - "--env-file", - ".env.local", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("exits non-zero on 422 invalid name", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Validation failed", errors: [{ message: "Invalid secret name" }] }, - }), - }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero when env file not found", async ({ run }) => { - const result = await run([ - "secrets", - "set", - "--env-file", - "nonexistent.env", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("sets a single secret", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "set", "FOO=bar", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("sets multiple secrets", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "set", "FOO=bar", "BAZ=qux", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("sets secrets from env file", ({ run, workspace, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + yield* writeSecretsEnvFile(workspace.path); + const result = yield* Effect.promise(() => + run(["secrets", "set", "--env-file", ".env.local", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 invalid name", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { + message: "Validation failed", + errors: [{ message: "Invalid secret name" }], + }, + }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero when env file not found", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "set", "--env-file", "nonexistent.env", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "set", "FOO=bar", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("secrets:unset", () => { - testBehaviour("removes a secret", async ({ run, projectRef }) => { - const result = await run(["secrets", "unset", "FOO", "--project-ref", projectRef, "--yes"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("removes multiple secrets", async ({ run, projectRef }) => { - await run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef]); - const result = await run([ - "secrets", - "unset", - "FOO", - "BAR", - "--project-ref", - projectRef, - "--yes", - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Finished"); - }); - - testBehaviour("exits non-zero on 404", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Secret not found" } }), - }); - const result = await run([ - "secrets", - "unset", - "NONEXISTENT", - "--project-ref", - PROJECT_REF, - "--yes", - ]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]); - expect(result.exitCode).not.toBe(0); - }); + testBehaviour("removes a secret", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["secrets", "unset", "FOO", "--project-ref", projectRef, "--yes"]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("removes multiple secrets", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + run(["secrets", "set", "FOO=bar", "BAR=baz", "--project-ref", projectRef]), + ); + const result = yield* Effect.promise(() => + run(["secrets", "unset", "FOO", "BAR", "--project-ref", projectRef, "--yes"]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Finished"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Secret not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "unset", "NONEXISTENT", "--project-ref", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["secrets", "unset", "FOO", "--project-ref", PROJECT_REF, "--yes"]), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/sso.e2e.test.ts b/apps/cli-e2e/src/tests/sso.e2e.test.ts index f717699f92..4dc1843de0 100644 --- a/apps/cli-e2e/src/tests/sso.e2e.test.ts +++ b/apps/cli-e2e/src/tests/sso.e2e.test.ts @@ -1,6 +1,7 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { isRecording, PROJECT_REF, PROVIDER_ID } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; @@ -11,630 +12,936 @@ const MINIMAL_SAML_XML = ` `; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + +const parseJson = (input: string) => + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(input); + +interface HttpRequestOptions extends Omit { + readonly body?: unknown; +} + +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); +} + +const writeMetadataFile = (workspacePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const metadataPath = path.join(workspacePath, "saml.xml"); + yield* fs.writeFileString(metadataPath, MINIMAL_SAML_XML); + return metadataPath; + }); + describe("sso", () => { describe("sso:list", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("IDENTITY PROVIDER ID"); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--output", "json", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ providers: [] }); - }); - - testBehaviour("includes debug output with --debug", async ({ run, projectRef }) => { - const result = await run(["sso", "list", "--debug", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stderr).toMatch(/HTTP.*GET:/); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 project not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "Project not found" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("SAML 2.0 support is not enabled"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "list", "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "list", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("IDENTITY PROVIDER ID"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "list", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(yield* parseJson(result.stdout)).toMatchObject({ providers: [] }); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("includes debug output with --debug", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "list", "--debug", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toMatch(/HTTP.*GET:/); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 project not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "Project not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("SAML 2.0 support is not enabled"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "list", "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("sso:info", () => { - testBehaviour("renders fixture data in output", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); - }); - - testBehaviour("returns json output with --output json", async ({ run, projectRef }) => { - const result = await run(["sso", "info", "--output", "json", "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toMatchObject({ - acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), - }); - }); + testBehaviour("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "info", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("supabase.co/auth/v1/sso/saml/acs"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("returns json output with --output json", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "info", "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(yield* parseJson(result.stdout)).toMatchObject({ + acs_url: expect.stringContaining("supabase.co/auth/v1/sso/saml/acs"), + }); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); // sso info makes no API calls — no error injection tests needed }); describe("sso:show", () => { - testBehaviour.skipIf(isRecording)( - "renders fixture data in output", - async ({ run, projectRef }) => { - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", projectRef]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + testBehaviour.skipIf(isRecording)("renders fixture data in output", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); testBehaviour.skipIf(isRecording)( "returns json output with --output json", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "show", - PROVIDER_ID, - "--output", - "json", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--output", "json", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); testBehaviour.skipIf(isRecording)( "shows raw SAML metadata XML with --metadata", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "show", - PROVIDER_ID, - "--metadata", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("EntityDescriptor"); - }, - ); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--metadata", "--project-ref", projectRef]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EntityDescriptor"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "SSO Identity Provider not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "show", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("sso:add", () => { - testBehaviour( - "adds SAML provider via metadata file", - async ({ run, projectRef, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--domains", - "example.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, - ); - - testBehaviour("exits non-zero without --type", async ({ run }) => { - const result = await run([ - "sso", - "add", - "--metadata-url", - "https://example.com/saml/metadata", - "--skip-url-validation", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain('"type"'); - }); + testBehaviour("adds SAML provider via metadata file", ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--domains", + "example.com", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero without --type", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--metadata-url", + "https://example.com/saml/metadata", + "--skip-url-validation", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain('"type"'); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); testBehaviour( "exits non-zero with both --metadata-url and --metadata-file", - async ({ run, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-url", - "https://example.com/saml/metadata", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("metadata"); - }, - ); - - testBehaviour("exits non-zero with unreachable --metadata-url", async ({ run }) => { - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-url", - "http://localhost:19999/saml.xml", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("HTTPS"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 422 invalid metadata", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - status: 422, - body: { message: "Invalid SAML metadata" }, - }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid SAML metadata"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "sso", - "add", - "--type", - "saml", - "--metadata-file", - metadataPath, - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-url", + "https://example.com/saml/metadata", + "--metadata-file", + metadataPath, + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("metadata"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero with unreachable --metadata-url", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-url", + "http://localhost:19999/saml.xml", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("HTTPS"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 422 invalid metadata", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { + status: 422, + body: { message: "Invalid SAML metadata" }, + }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid SAML metadata"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "add", + "--type", + "saml", + "--metadata-file", + metadataPath, + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("sso:update", () => { - testBehaviour.skipIf(isRecording)( - "appends domain with --add-domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }, + testBehaviour.skipIf(isRecording)("appends domain with --add-domains", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); - testBehaviour.skipIf(isRecording)( - "replaces domains with --domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--domains", - "new.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("new.com"); - }, + testBehaviour.skipIf(isRecording)("replaces domains with --domains", ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--domains", + "new.com", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("new.com"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); testBehaviour.skipIf(isRecording)( "removes domain with --remove-domains", - async ({ run, projectRef }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--remove-domains", - "example.com", - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("IDENTITY PROVIDER ID"); - }, + ({ run, projectRef }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--remove-domains", + "example.com", + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("IDENTITY PROVIDER ID"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); testBehaviour.skipIf(isRecording)( "updates metadata via metadata file", - async ({ run, projectRef, workspace }) => { - const metadataPath = join(workspace.path, "saml.xml"); - writeFileSync(metadataPath, MINIMAL_SAML_XML); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--metadata-file", - metadataPath, - "--project-ref", - projectRef, - ]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("EntityDescriptor"); - }, - ); - - testBehaviour("exits non-zero with --domains and --add-domains", async ({ run }) => { - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--domains", - "a.com", - "--add-domains", - "b.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("domains"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run([ - "sso", - "update", - PROVIDER_ID, - "--add-domains", - "example.com", - "--project-ref", - PROJECT_REF, - ]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + ({ run, projectRef, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const metadataPath = yield* writeMetadataFile(workspace.path); + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--metadata-file", + metadataPath, + "--project-ref", + projectRef, + ]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EntityDescriptor"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero with --domains and --add-domains", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("domains"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "SSO Identity Provider not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run([ + "sso", + "update", + PROVIDER_ID, + "--add-domains", + "example.com", + "--project-ref", + PROJECT_REF, + ]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("sso:remove", () => { - testBehaviour.skipIf(isRecording)("removes a provider", async ({ run }) => { - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("example.com"); - }); - - testBehaviour("exits non-zero on 401", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 401, body: { message: "Invalid token" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Invalid token"); - }); - - testBehaviour("exits non-zero on 403", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 403, body: { message: "Forbidden" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Forbidden"); - }); - - testBehaviour("exits non-zero on 404 provider not found", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 404, body: { message: "SSO Identity Provider not found" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("could not be found"); - }); - - testBehaviour("exits non-zero on 429", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 429, body: { message: "Too Many Requests" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Too Many Requests"); - }); - - testBehaviour("exits non-zero on 500", async ({ run, apiUrl }) => { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: 500, body: { message: "Internal Server Error" } }), - }); - const result = await run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]); - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("Internal Server Error"); - }); + testBehaviour.skipIf(isRecording)("removes a provider", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => + run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("example.com"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 401", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 401, body: { message: "Invalid token" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Invalid token"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 403", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 403, body: { message: "Forbidden" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Forbidden"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 404 provider not found", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 404, body: { message: "SSO Identity Provider not found" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("could not be found"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 429", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 429, body: { message: "Too Many Requests" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Too Many Requests"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); + + testBehaviour("exits non-zero on 500", ({ run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status: 500, body: { message: "Internal Server Error" } }, + }), + ); + const result = yield* Effect.promise(() => + run(["sso", "remove", PROVIDER_ID, "--project-ref", PROJECT_REF]), + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("Internal Server Error"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index f7af06f0ab..b8509ac63d 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -1,6 +1,7 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect, inject, test } from "vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { createHarness, exec, type CLIResult } from "@supabase/cli-test-helpers"; import { testBehaviour } from "./test-context.ts"; import { ACCESS_TOKEN, TARGET } from "./env.ts"; @@ -8,12 +9,19 @@ import { ACCESS_TOKEN, TARGET } from "./env.ts"; // A guaranteed-unreachable TCP address — connection is refused immediately. // Used to simulate Docker being unavailable without relying on any external state. const UNREACHABLE_DOCKER_HOST = "tcp://localhost:1"; +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); // Minimal config.toml required by start/stop/status. -function setupStackWorkspace(dir: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "config.toml"), 'project_id = "test-project"\n'); -} +const setupStackWorkspace = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + 'project_id = "test-project"\n', + ); + }); // Extends testBehaviour with a `stackRun` fixture that automatically passes // DOCKER_HOST to the CLI subprocess, pointing it at the relay server. @@ -26,7 +34,7 @@ interface StackFixtures { } const testStack = testBehaviour.extend({ - stackRun: async ({ workspace }, use) => { + stackRun: ({ workspace }, use) => { const serverUrl = inject("replayServerUrl") as string; const dockerHostUrl = inject("dockerHostUrl") as string; const harness = createHarness(TARGET, { @@ -34,8 +42,12 @@ const testStack = testBehaviour.extend({ accessToken: ACCESS_TOKEN, cwd: workspace.path, }); - await use((cmd, opts) => - exec(harness, cmd, { env: { DOCKER_HOST: opts?.dockerHost ?? dockerHostUrl } }), + return Effect.runPromise( + Effect.promise(() => + use((cmd, opts) => + exec(harness, cmd, { env: { DOCKER_HOST: opts?.dockerHost ?? dockerHostUrl } }), + ), + ), ); }, }); @@ -47,14 +59,18 @@ const testStack = testBehaviour.extend({ // needed. describe("services", () => { - testBehaviour("lists known service images", async ({ run }) => { - const result = await run(["services"]); - expect(result.exitCode).toBe(0); - // Output is a pipe-separated markdown table; verify well-known image names appear. - expect(result.stdout).toContain("postgres"); - expect(result.stdout).toContain("gotrue"); - expect(result.stdout).toContain("storage"); - }); + testBehaviour("lists known service images", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["services"])); + expect(result.exitCode).toBe(0); + // Output is a pipe-separated markdown table; verify well-known image names appear. + expect(result.stdout).toContain("postgres"); + expect(result.stdout).toContain("gotrue"); + expect(result.stdout).toContain("storage"); + }), + ), + ); }); // --------------------------------------------------------------------------- @@ -64,14 +80,18 @@ describe("services", () => { // CLI-2167: `status` (ts-legacy only) resolves and prints the current linked // project/branch on stdout, before any Docker/daemon work runs, in every // output mode — an adjudicated, deliberate TS-only extension with no Go -// counterpart (Go's `status` never had a link-state concept). Go's stdout for +// counterpart (Go's `status` never had a link-state concept). describe("status", () => { - testStack("exits 1 when stack is not running", async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["status"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/no such container/i); - }); + testStack("exits 1 when stack is not running", ({ workspace, stackRun }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* Effect.promise(() => stackRun(["status"])); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/no such container/i); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -79,22 +99,31 @@ describe("status", () => { // --------------------------------------------------------------------------- describe("stop", () => { - testStack("succeeds when stack is not running", async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["stop"]); - expect(result.exitCode).toBe(0); - }); + testStack("succeeds when stack is not running", ({ workspace, stackRun }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* Effect.promise(() => stackRun(["stop"])); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); // cobra's MarkFlagsMutuallyExclusive validates this before the command runs — // no Docker or API calls are made. testStack( "exits 1 with mutual-exclusion error for --project-id and --all", - async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - const result = await stackRun(["stop", "--project-id", "test-project", "--all"]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/mutually exclusive|if any flags in the group.*are set/i); - }, + ({ workspace, stackRun }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + const result = yield* Effect.promise(() => + stackRun(["stop", "--project-id", "test-project", "--all"]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/mutually exclusive|if any flags in the group.*are set/i); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); }); @@ -103,16 +132,19 @@ describe("stop", () => { // --------------------------------------------------------------------------- describe("start", () => { - testStack( - "exits 1 with Docker error when Docker is unavailable", - async ({ workspace, stackRun }) => { - setupStackWorkspace(workspace.path); - // Use an unreachable host so connection fails immediately without waiting - // for a timeout. The relay has no special handling for this case. - const result = await stackRun(["start"], { dockerHost: UNREACHABLE_DOCKER_HOST }); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - }, + testStack("exits 1 with Docker error when Docker is unavailable", ({ workspace, stackRun }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStackWorkspace(workspace.path); + // Use an unreachable host so connection fails immediately without waiting + // for a timeout. The relay has no special handling for this case. + const result = yield* Effect.promise(() => + stackRun(["start"], { dockerHost: UNREACHABLE_DOCKER_HOST }), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), ); // start → status → status --override-name → stop lifecycle test. @@ -130,30 +162,42 @@ describe("start", () => { // `seed buckets` makes storage HTTP calls (not Docker), so plain testBehaviour // with `run` is correct. +const RequestLogSchema = Schema.Array( + Schema.Struct({ method: Schema.String, pathname: Schema.String }), +); + describe("seed buckets", () => { - testBehaviour("creates buckets defined in config", async ({ workspace, run, apiUrl }) => { - mkdirSync(join(workspace.path, "supabase"), { recursive: true }); - writeFileSync( - join(workspace.path, "supabase", "config.toml"), - [ - 'project_id = "test-project"', - "", - "[api]", - // Point the local stack API at the relay server so bucket creation - // calls are captured. - `port = ${new URL(apiUrl).port}`, - "", - "[storage.buckets.my-bucket]", - "public = false", - ].join("\n"), - ); - const result = await run(["seed", "buckets"]); - expect(result.exitCode).toBe(0); - const requests = await fetch(`${apiUrl}/_ctrl/requests`).then( - (r) => r.json() as Promise>, - ); - expect(requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket")).toBe( - true, - ); - }); + testBehaviour("creates buckets defined in config", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workspace.path, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workspace.path, "supabase", "config.toml"), + [ + 'project_id = "test-project"', + "", + "[api]", + // Point the local stack API at the relay server so bucket creation + // calls are captured. + `port = ${new URL(apiUrl).port}`, + "", + "[storage.buckets.my-bucket]", + "public = false", + ].join("\n"), + ); + const result = yield* Effect.promise(() => run(["seed", "buckets"])); + expect(result.exitCode).toBe(0); + const response = yield* HttpClient.execute( + HttpClientRequest.get(`${apiUrl}/_ctrl/requests`), + ); + const body = yield* response.text; + const requests = yield* Schema.decodeEffect(Schema.fromJsonString(RequestLogSchema))(body); + expect( + requests.some((r) => r.method === "POST" && r.pathname === "/storage/v1/bucket"), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index 257009d57b..287d159a20 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -1,109 +1,181 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpMethod } from "effect/unstable/http"; import { testBehaviour } from "./test-context.ts"; const BUCKET = "cli-e2e-bucket"; const LOCAL_FLAGS = ["--experimental", "--local"]; -function setupStorageWorkspace(dir: string, relayUrl: string): void { - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync( - join(dir, "supabase", "config.toml"), - ['project_id = "test-project"', "", "[api]", `external_url = "${relayUrl}"`].join("\n"), - ); - writeFileSync(join(dir, "upload.txt"), "test upload content"); -} +const testLayer = Layer.mergeAll(FetchHttpClient.layer, BunFileSystem.layer, BunPath.layer); + +const RequestEntrySchema = Schema.Struct({ + method: Schema.String, + pathname: Schema.String, + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Unknown, +}); + +const RequestLogSchema = Schema.Array(RequestEntrySchema); -interface RequestEntry { - method: string; - pathname: string; - headers: Record; - body: unknown; +interface HttpRequestOptions extends Omit { + readonly body?: unknown; } -async function getRequestLog(apiUrl: string): Promise { - const res = await fetch(`${apiUrl}/_ctrl/requests`); - return res.json() as Promise; +function httpRequest(input: string, init: HttpRequestOptions): Promise { + return Effect.runPromise( + Effect.gen(function* () { + const method = init.method ?? "GET"; + if (!HttpMethod.isHttpMethod(method)) { + return yield* Effect.die(new Error(`Unsupported HTTP method: ${method}`)); + } + let request = HttpClientRequest.make(method)(input, { + headers: init.headers === undefined ? {} : new globalThis.Headers(init.headers), + }); + if (init.body !== undefined) { + request = yield* HttpClientRequest.bodyJson(request, init.body); + } + const response = yield* HttpClient.execute(request); + const body = yield* response.arrayBuffer; + return new Response(body, { status: response.status, headers: { ...response.headers } }); + }).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); } -async function injectGlobalError(apiUrl: string, status: number, message: string): Promise { - await fetch(`${apiUrl}/_ctrl/error-all`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status, body: { message } }), +const setupStorageWorkspace = (dir: string, relayUrl: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[api]", `external_url = "${relayUrl}"`].join("\n"), + ); + yield* fs.writeFileString(path.join(dir, "upload.txt"), "test upload content"); }); -} -async function clearOverrides(apiUrl: string): Promise { - await fetch(`${apiUrl}/_ctrl/overrides`, { method: "DELETE" }); -} +const getRequestLog = (apiUrl: string) => + Effect.gen(function* () { + const response = yield* Effect.promise(() => httpRequest(`${apiUrl}/_ctrl/requests`, {})); + const body = yield* Effect.promise(() => response.text()); + return yield* Schema.decodeEffect(Schema.fromJsonString(RequestLogSchema))(body); + }); + +const injectGlobalError = (apiUrl: string, status: number, message: string) => + Effect.promise(() => + httpRequest(`${apiUrl}/_ctrl/error-all`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { status, body: { message } }, + }), + ).pipe(Effect.asVoid); + +const clearOverrides = (apiUrl: string) => + Effect.promise(() => httpRequest(`${apiUrl}/_ctrl/overrides`, { method: "DELETE" })).pipe( + Effect.asVoid, + ); // --------------------------------------------------------------------------- // storage ls // --------------------------------------------------------------------------- describe("storage ls", () => { - testBehaviour("lists objects in bucket", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("hello.txt"); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, - ), - ).toBe(true); - }); + testBehaviour("lists objects in bucket", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]), + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("hello.txt"); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, + ), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("lists objects recursively", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, "-r", `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, - ), - ).toBe(true); - }); + testBehaviour("lists objects recursively", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run(["storage", "ls", ...LOCAL_FLAGS, "-r", `ss:///${BUCKET}/`]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/list/${BUCKET}`, + ), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* Effect.promise(() => + run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* Effect.promise(() => + run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* Effect.promise(() => + run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* Effect.promise(() => + run(["storage", "ls", ...LOCAL_FLAGS, `ss:///${BUCKET}/`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -111,147 +183,159 @@ describe("storage ls", () => { // --------------------------------------------------------------------------- describe("storage cp", () => { - testBehaviour("uploads local file to storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - expect( - requests.some( - (r) => r.method === "POST" && r.pathname === `/storage/v1/object/${BUCKET}/upload.txt`, - ), - ).toBe(true); - }); + testBehaviour("uploads local file to storage", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + expect( + requests.some( + (r) => r.method === "POST" && r.pathname === `/storage/v1/object/${BUCKET}/upload.txt`, + ), + ).toBe(true); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("passes --cache-control header on upload", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "--cache-control", - "no-cache", - "upload.txt", - `ss:///${BUCKET}/cached.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const uploadReq = requests.find( - (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), - ); - expect(uploadReq).toBeDefined(); - expect(uploadReq?.headers["cache-control"]).toBe("no-cache"); - }); + testBehaviour("passes --cache-control header on upload", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run([ + "storage", + "cp", + ...LOCAL_FLAGS, + "--cache-control", + "no-cache", + "upload.txt", + `ss:///${BUCKET}/cached.txt`, + ]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const uploadReq = requests.find( + (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), + ); + expect(uploadReq).toBeDefined(); + expect(uploadReq?.headers["cache-control"]).toBe("no-cache"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("passes --content-type header on upload", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "--content-type", - "application/json", - "upload.txt", - `ss:///${BUCKET}/typed.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const uploadReq = requests.find( - (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), - ); - expect(uploadReq).toBeDefined(); - expect(uploadReq?.headers["content-type"]).toContain("application/json"); - }); + testBehaviour("passes --content-type header on upload", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run([ + "storage", + "cp", + ...LOCAL_FLAGS, + "--content-type", + "application/json", + "upload.txt", + `ss:///${BUCKET}/typed.txt`, + ]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const uploadReq = requests.find( + (r) => r.method === "POST" && r.pathname.startsWith(`/storage/v1/object/${BUCKET}/`), + ); + expect(uploadReq).toBeDefined(); + expect(uploadReq?.headers["content-type"]).toContain("application/json"); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 when source file not found", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "nonexistent.txt", - `ss:///${BUCKET}/x.txt`, - ]); - expect(result.exitCode).toBe(1); - }); + testBehaviour("exits 1 when source file not found", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "nonexistent.txt", `ss:///${BUCKET}/x.txt`]), + ); + expect(result.exitCode).toBe(1); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - "upload.txt", - `ss:///${BUCKET}/upload.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/upload.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("downloads file from storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - const result = await run([ - "storage", - "cp", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/hello.txt`, - "hello-download.txt", - ]); - expect(result.exitCode).toBe(0); - }); + testBehaviour("downloads file from storage", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const result = yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, `ss:///${BUCKET}/hello.txt`, "hello-download.txt"]), + ); + expect(result.exitCode).toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -259,89 +343,97 @@ describe("storage cp", () => { // --------------------------------------------------------------------------- describe("storage mv", () => { - testBehaviour("moves file within bucket", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - // Upload source file so the move has something to move in staging. - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/mv-source.txt`]); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/mv-source.txt`, - `ss:///${BUCKET}/mv-dest.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const moveReq = requests.find( - (r) => r.method === "POST" && r.pathname === "/storage/v1/object/move", - ); - expect(moveReq).toBeDefined(); - expect(moveReq?.body).toMatchObject({ - bucketId: BUCKET, - sourceKey: "mv-source.txt", - destinationKey: "mv-dest.txt", - }); - }); + testBehaviour("moves file within bucket", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + // Upload source file so the move has something to move in staging. + yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/mv-source.txt`]), + ); + const result = yield* Effect.promise(() => + run([ + "storage", + "mv", + ...LOCAL_FLAGS, + `ss:///${BUCKET}/mv-source.txt`, + `ss:///${BUCKET}/mv-dest.txt`, + ]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const moveReq = requests.find( + (r) => r.method === "POST" && r.pathname === "/storage/v1/object/move", + ); + expect(moveReq).toBeDefined(); + expect(moveReq?.body).toMatchObject({ + bucketId: BUCKET, + sourceKey: "mv-source.txt", + destinationKey: "mv-dest.txt", + }); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* Effect.promise(() => + run(["storage", "mv", ...LOCAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* Effect.promise(() => + run(["storage", "mv", ...LOCAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* Effect.promise(() => + run(["storage", "mv", ...LOCAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "mv", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/a.txt`, - `ss:///${BUCKET}/b.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* Effect.promise(() => + run(["storage", "mv", ...LOCAL_FLAGS, `ss:///${BUCKET}/a.txt`, `ss:///${BUCKET}/b.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); // --------------------------------------------------------------------------- @@ -349,105 +441,119 @@ describe("storage mv", () => { // --------------------------------------------------------------------------- describe("storage rm", () => { - testBehaviour("removes a file from storage", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - // Upload the file to remove. - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-target.txt`]); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/rm-target.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const rmReq = requests.find( - (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, - ); - expect(rmReq).toBeDefined(); - expect(rmReq?.body).toMatchObject({ prefixes: ["rm-target.txt"] }); - }); + testBehaviour("removes a file from storage", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + // Upload the file to remove. + yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-target.txt`]), + ); + const result = yield* Effect.promise(() => + run(["storage", "rm", "--yes", ...LOCAL_FLAGS, `ss:///${BUCKET}/rm-target.txt`]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const rmReq = requests.find( + (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, + ); + expect(rmReq).toBeDefined(); + expect(rmReq?.body).toMatchObject({ prefixes: ["rm-target.txt"] }); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("removes multiple files", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - writeFileSync(join(workspace.path, "file2.txt"), "second file"); - await run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-a.txt`]); - await run(["storage", "cp", ...LOCAL_FLAGS, "file2.txt", `ss:///${BUCKET}/rm-b.txt`]); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/rm-a.txt`, - `ss:///${BUCKET}/rm-b.txt`, - ]); - expect(result.exitCode).toBe(0); - const requests = await getRequestLog(apiUrl); - const rmReq = requests.find( - (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, - ); - expect(rmReq).toBeDefined(); - expect(rmReq?.body).toMatchObject({ prefixes: ["rm-a.txt", "rm-b.txt"] }); - }); + testBehaviour("removes multiple files", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(workspace.path, "file2.txt"), "second file"); + yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "upload.txt", `ss:///${BUCKET}/rm-a.txt`]), + ); + yield* Effect.promise(() => + run(["storage", "cp", ...LOCAL_FLAGS, "file2.txt", `ss:///${BUCKET}/rm-b.txt`]), + ); + const result = yield* Effect.promise(() => + run([ + "storage", + "rm", + "--yes", + ...LOCAL_FLAGS, + `ss:///${BUCKET}/rm-a.txt`, + `ss:///${BUCKET}/rm-b.txt`, + ]), + ); + expect(result.exitCode).toBe(0); + const requests = yield* getRequestLog(apiUrl); + const rmReq = requests.find( + (r) => r.method === "DELETE" && r.pathname === `/storage/v1/object/${BUCKET}`, + ); + expect(rmReq).toBeDefined(); + expect(rmReq?.body).toMatchObject({ prefixes: ["rm-a.txt", "rm-b.txt"] }); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 401", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 401, "Invalid token"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Invalid token"); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 401", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 401, "Invalid token"); + const result = yield* Effect.promise(() => + run(["storage", "rm", "--yes", ...LOCAL_FLAGS, `ss:///${BUCKET}/file.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Invalid token"); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 403", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 403, "Forbidden"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 403", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 403, "Forbidden"); + const result = yield* Effect.promise(() => + run(["storage", "rm", "--yes", ...LOCAL_FLAGS, `ss:///${BUCKET}/file.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 429", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 429, "Too Many Requests"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 429", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 429, "Too Many Requests"); + const result = yield* Effect.promise(() => + run(["storage", "rm", "--yes", ...LOCAL_FLAGS, `ss:///${BUCKET}/file.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); - testBehaviour("exits 1 on 500", async ({ workspace, run, apiUrl }) => { - setupStorageWorkspace(workspace.path, apiUrl); - await injectGlobalError(apiUrl, 500, "Internal Server Error"); - const result = await run([ - "storage", - "rm", - "--yes", - ...LOCAL_FLAGS, - `ss:///${BUCKET}/file.txt`, - ]); - expect(result.exitCode).toBe(1); - expect(result.stderr.length).toBeGreaterThan(0); - await clearOverrides(apiUrl); - }); + testBehaviour("exits 1 on 500", ({ workspace, run, apiUrl }) => + Effect.runPromise( + Effect.gen(function* () { + yield* setupStorageWorkspace(workspace.path, apiUrl); + yield* injectGlobalError(apiUrl, 500, "Internal Server Error"); + const result = yield* Effect.promise(() => + run(["storage", "rm", "--yes", ...LOCAL_FLAGS, `ss:///${BUCKET}/file.txt`]), + ); + expect(result.exitCode).toBe(1); + expect(result.stderr.length).toBeGreaterThan(0); + yield* clearOverrides(apiUrl); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); diff --git a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts index 9d6bc4e942..099c300edb 100644 --- a/apps/cli-e2e/src/tests/telemetry.e2e.test.ts +++ b/apps/cli-e2e/src/tests/telemetry.e2e.test.ts @@ -1,63 +1,96 @@ -import { chmodSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { testBehaviour } from "./test-context.ts"; +const testLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); +const JsonValue = Schema.fromJsonString(Schema.Unknown); + describe("telemetry", () => { describe("telemetry:enable", () => { - testBehaviour("enables telemetry", async ({ run }) => { - const result = await run(["telemetry", "enable"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - }); - - testBehaviour("exits non-zero on unwritable config dir", async ({ run, workspace }) => { - chmodSync(workspace.path, 0o555); - try { - const result = await run(["telemetry", "enable"]); - expect(result.exitCode).not.toBe(0); - } finally { - chmodSync(workspace.path, 0o755); - } - }); + testBehaviour("enables telemetry", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["telemetry", "enable"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + }), + ), + ); + + testBehaviour("exits non-zero on unwritable config dir", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.chmod(workspace.path, 0o555); + const result = yield* Effect.promise(() => run(["telemetry", "enable"])).pipe( + Effect.ensuring(fs.chmod(workspace.path, 0o755).pipe(Effect.orDie)), + ); + expect(result.exitCode).not.toBe(0); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); describe("telemetry:disable", () => { - testBehaviour("disables telemetry", async ({ run }) => { - const result = await run(["telemetry", "disable"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is disabled."); - }); + testBehaviour("disables telemetry", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["telemetry", "disable"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is disabled."); + }), + ), + ); }); describe("telemetry:status", () => { - testBehaviour("shows current telemetry state", async ({ run }) => { - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); - }); - - testBehaviour("round-trip: enable then status shows enabled", async ({ run }) => { - await run(["telemetry", "enable"]); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - }); - - testBehaviour("round-trip: disable then status shows disabled", async ({ run }) => { - await run(["telemetry", "disable"]); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is disabled."); - }); - - testBehaviour("handles corrupted config gracefully", async ({ run, workspace }) => { - const telemetryPath = join(workspace.path, "telemetry.json"); - writeFileSync(telemetryPath, "{{not valid json}}"); - const result = await run(["telemetry", "status"]); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Telemetry is enabled."); - expect(() => JSON.parse(readFileSync(telemetryPath, "utf8"))).not.toThrow(); - }); + testBehaviour("shows current telemetry state", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* Effect.promise(() => run(["telemetry", "status"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/Telemetry is (enabled|disabled)\./); + }), + ), + ); + + testBehaviour("round-trip: enable then status shows enabled", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => run(["telemetry", "enable"])); + const result = yield* Effect.promise(() => run(["telemetry", "status"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + }), + ), + ); + + testBehaviour("round-trip: disable then status shows disabled", ({ run }) => + Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => run(["telemetry", "disable"])); + const result = yield* Effect.promise(() => run(["telemetry", "status"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is disabled."); + }), + ), + ); + + testBehaviour("handles corrupted config gracefully", ({ run, workspace }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const telemetryPath = path.join(workspace.path, "telemetry.json"); + yield* fs.writeFileString(telemetryPath, "{{not valid json}}"); + const result = yield* Effect.promise(() => run(["telemetry", "status"])); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Telemetry is enabled."); + const content = yield* fs.readFileString(telemetryPath); + yield* Schema.decodeEffect(JsonValue)(content); + }).pipe(Effect.provide(testLayer), Effect.orDie), + ), + ); }); }); diff --git a/apps/cli-e2e/src/tests/test-context.ts b/apps/cli-e2e/src/tests/test-context.ts index 50d1c6aec3..dd94f2e1d7 100644 --- a/apps/cli-e2e/src/tests/test-context.ts +++ b/apps/cli-e2e/src/tests/test-context.ts @@ -1,4 +1,6 @@ import { inject, test } from "vitest"; +import { Data, Effect } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; import { createHarness, exec, @@ -20,6 +22,46 @@ function scenarioSlug(task: { name: string; suite?: { name: string } | null }): return prefix + slugify(task.name); } +class ReplayControlError extends Data.TaggedError("ReplayControlError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +function toControlError(cause: unknown): ReplayControlError { + return new ReplayControlError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }); +} + +function controlRequest( + serverUrl: string, + path: string, + method: "DELETE" | "POST", + body?: unknown, +): Effect.Effect { + return Effect.gen(function* () { + const request = + body === undefined + ? HttpClientRequest.make(method)(`${serverUrl}${path}`) + : yield* HttpClientRequest.make(method)(`${serverUrl}${path}`).pipe( + HttpClientRequest.bodyJson(body), + ); + const response = yield* HttpClient.execute(request); + if (response.status >= 200 && response.status < 300) return; + const payload = yield* response.json.pipe(Effect.orElseSucceed(() => undefined)); + const messageValue = + typeof payload === "object" && payload !== null && "message" in payload + ? payload.message + : undefined; + const message = + typeof messageValue === "string" + ? messageValue + : `Replay control request failed (${response.status})`; + return yield* new ReplayControlError({ message }); + }).pipe(Effect.mapError(toControlError)); +} + type ExecOptions = NonNullable[2]>; interface BehaviourFixtures { @@ -47,44 +89,44 @@ interface BehaviourFixtures { * server knows which ordered interaction sequence to serve. Auto-clears the * request log, error overrides, and active scenario after every test. */ export const testBehaviour = test.extend({ - // eslint-disable-next-line no-empty-pattern - projectRef: async ({}, use) => { - await use(inject("projectRef") as string); + projectRef: ({ task: _task }, use) => { + return use(inject("projectRef") as string); }, - // eslint-disable-next-line no-empty-pattern - orgId: async ({}, use) => { - await use(inject("orgId") as string); + orgId: ({ task: _task }, use) => { + return use(inject("orgId") as string); }, - workspace: async ({ task }, use) => { + workspace: ({ task }, use) => { const serverUrl = inject("replayServerUrl"); const slug = scenarioSlug(task); // Truncate to 40 chars to keep temp dir names manageable. - const dir = makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`); - - const res = await fetch(`${serverUrl}/_ctrl/scenario`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: slug }), - }); - if (!res.ok) { - dir[Symbol.dispose](); - const { message } = (await res.json()) as { message: string }; - throw new Error(message); - } - - await use(dir); - - dir[Symbol.dispose](); - await Promise.all([ - fetch(`${serverUrl}/_ctrl/requests`, { method: "DELETE" }), - fetch(`${serverUrl}/_ctrl/overrides`, { method: "DELETE" }), - fetch(`${serverUrl}/_ctrl/scenario`, { method: "DELETE" }), - ]); + return Effect.runPromise( + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => makeTempDir(`cli-e2e-${slug.slice(0, 40)}-`), + catch: toControlError, + }), + (dir) => + Effect.gen(function* () { + yield* controlRequest(serverUrl, "/_ctrl/scenario", "POST", { name: slug }); + yield* Effect.tryPromise({ try: () => use(dir), catch: toControlError }); + }), + (dir) => + Effect.gen(function* () { + yield* controlRequest(serverUrl, "/_ctrl/requests", "DELETE"); + yield* controlRequest(serverUrl, "/_ctrl/overrides", "DELETE"); + yield* controlRequest(serverUrl, "/_ctrl/scenario", "DELETE"); + yield* Effect.tryPromise({ + try: () => dir[Symbol.asyncDispose](), + catch: toControlError, + }); + }), + ).pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie), + ); }, - run: async ({ workspace }, use) => { + run: ({ workspace }, use) => { const serverUrl = inject("replayServerUrl"); const harness = createHarness(TARGET, { apiUrl: serverUrl, @@ -92,31 +134,28 @@ export const testBehaviour = test.extend({ cwd: workspace.path, projectId: inject("projectRef") as string, }); - await use((cmd, execOpts) => exec(harness, cmd, execOpts)); + return use((cmd, execOpts) => exec(harness, cmd, execOpts)); }, - runNoProjectId: async ({ workspace }, use) => { + runNoProjectId: ({ workspace }, use) => { const serverUrl = inject("replayServerUrl"); const harness = createHarness(TARGET, { apiUrl: serverUrl, accessToken: ACCESS_TOKEN, cwd: workspace.path, }); - await use((cmd) => exec(harness, cmd)); + return use((cmd) => exec(harness, cmd)); }, - // eslint-disable-next-line no-empty-pattern - storageBucket: async ({}, use) => { - await use(inject("storageBucket") as string); + storageBucket: ({ task: _task }, use) => { + return use(inject("storageBucket") as string); }, - // eslint-disable-next-line no-empty-pattern - apiUrl: async ({}, use) => { - await use(inject("replayServerUrl")); + apiUrl: ({ task: _task }, use) => { + return use(inject("replayServerUrl")); }, - // eslint-disable-next-line no-empty-pattern - pgMockPort: async ({}, use) => { - await use(inject("pgMockPort") as number); + pgMockPort: ({ task: _task }, use) => { + return use(inject("pgMockPort") as number); }, }); diff --git a/apps/cli-e2e/tests/setup.ts b/apps/cli-e2e/tests/setup.ts index 76395763ac..867f26f867 100644 --- a/apps/cli-e2e/tests/setup.ts +++ b/apps/cli-e2e/tests/setup.ts @@ -1,7 +1,21 @@ import type { ProvidedContext } from "vitest"; +import { Effect, Schema } from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; import { startPgMock } from "../src/server/pg-mock.ts"; import { startReplayServer } from "../src/server/replay-server.ts"; -import { ACCESS_TOKEN, isRecording, ORG_ID, PROJECT_REF } from "../src/tests/env.ts"; +import { + ACCESS_TOKEN, + isRecording, + ORG_ID, + PROJECT_REF, + readEnv, + TARGET_API_URL, +} from "../src/tests/env.ts"; import { cleanupProjectsByName, createTestProject, @@ -14,117 +28,145 @@ import "./provided-context.ts"; // centralized `inject()` key augmentation const FIXTURES_DIR = new URL("../fixtures", import.meta.url).pathname; -function resolveDockerSocket(): string { - const dockerHost = process.env["DOCKER_HOST"]; +const serviceRoleKeysSchema = Schema.Array( + Schema.Struct({ name: Schema.String, api_key: Schema.String }), +); + +function resolveDockerSocket(dockerHost = readEnv("DOCKER_HOST")): string { if (dockerHost?.startsWith("unix://")) return dockerHost.slice("unix://".length); return "/var/run/docker.sock"; } -export async function setup({ +export function setup({ provide, }: { provide: (key: K, value: ProvidedContext[K]) => void; }) { - const pgMock = startPgMock(); - provide("pgMockPort", pgMock.port); - - const server = await startReplayServer({ fixturesDir: FIXTURES_DIR, pgMock }); - provide("replayServerUrl", server.url); - - // Docker host URL: relay server in TCP form so DOCKER_HOST env can point at it. - // In record mode the relay proxies to the real Docker socket; in replay mode it - // serves recorded Docker API fixtures unchanged. - const dockerHostUrl = server.url.replace(/^http:\/\//, "tcp://"); - provide("dockerHostUrl", dockerHostUrl); - - if (!isRecording) { - // Replay mode — no real API calls; any valid 20-char string works as the - // project ref because fixture paths normalize it to __PROJECT_REF__. - provide("projectRef", PROJECT_REF); - provide("orgId", ORG_ID); - provide("storageBucket", "cli-e2e-bucket"); - return async () => { - pgMock.stop(); - await server.stop(); - }; - } - - // Record mode — wire up Docker proxy so Docker SDK calls (via DOCKER_HOST) are - // intercepted by the relay server and forwarded to the real Docker socket. - server.setDockerProxyUrl(resolveDockerSocket()); - - // Record mode — resolve org, then wipe any projects left over from previous - // failed recording runs before creating a fresh dedicated test project. - const orgId = await resolveOrgId(server.url); - - // Delete any orphaned projects whose names would conflict with what the tests - // are about to create. Runs before any scenario is loaded so these API calls - // go straight to staging and are not captured in any scenario fixture. - await cleanupProjectsByName(server.url, ["cli-e2e-test", "my-project", "to-delete"]); - - // Create a fresh project for this recording run. Its ref is used by branches, - // functions, secrets, and api-keys tests. - const projectRef = await createTestProject( - server.url, - orgId, - "cli-e2e-test", - generateDbPassword(), + return Effect.runPromise( + Effect.gen(function* () { + const pgMock = startPgMock(); + provide("pgMockPort", pgMock.port); + + const server = yield* Effect.promise(() => + startReplayServer({ + fixturesDir: FIXTURES_DIR, + pgMock, + mode: isRecording ? "record" : "replay", + stagingUrl: TARGET_API_URL, + }), + ); + provide("replayServerUrl", server.url); + + // Docker host URL: relay server in TCP form so DOCKER_HOST env can point at it. + // In record mode the relay proxies to the real Docker socket; in replay mode it + // serves recorded Docker API fixtures unchanged. + const dockerHostUrl = server.url.replace(/^http:\/\//, "tcp://"); + provide("dockerHostUrl", dockerHostUrl); + + if (!isRecording) { + // Replay mode — no real API calls; any valid 20-char string works as the + // project ref because fixture paths normalize it to __PROJECT_REF__. + provide("projectRef", PROJECT_REF); + provide("orgId", ORG_ID); + provide("storageBucket", "cli-e2e-bucket"); + const context = yield* Effect.context(); + return () => + Effect.runPromiseWith(context)( + Effect.promise(() => server.stop()).pipe( + Effect.tap(() => Effect.sync(() => pgMock.stop())), + ), + ); + } + + // Record mode — wire up Docker proxy so Docker SDK calls (via DOCKER_HOST) are + // intercepted by the relay server and forwarded to the real Docker socket. + server.setDockerProxyUrl(resolveDockerSocket()); + + // Record mode — resolve org, then wipe any projects left over from previous + // failed recording runs before creating a fresh dedicated test project. + const orgId = yield* Effect.promise(() => resolveOrgId(server.url)); + + // Delete any orphaned projects whose names would conflict with what the tests + // are about to create. Runs before any scenario is loaded so these API calls go + // straight to staging and are not captured in any scenario fixture. + yield* Effect.promise(() => + cleanupProjectsByName(server.url, ["cli-e2e-test", "my-project", "to-delete"]), + ); + + // Create a fresh project for this recording run. Its ref is used by branches, + // functions, secrets, and api-keys tests. + const projectRef = yield* Effect.promise(() => + createTestProject(server.url, orgId, "cli-e2e-test", generateDbPassword()), + ); + provide("projectRef", projectRef); + provide("orgId", orgId); + + // Wire storage proxy so /storage/v1/ calls from --local mode reach staging. + const stagingApiUrl = TARGET_API_URL; + // Wait for the project to be fully initialised before fetching api-keys — the + // api-keys endpoint is unavailable while the project is in COMING_SOON/BUILDING state. + yield* Effect.promise(() => waitForProjectReady(stagingApiUrl, projectRef)); + + // Retry api-keys fetch: even after ACTIVE_HEALTHY, the endpoint may briefly return 4xx. + let serviceRoleKey = ""; + for (let attempt = 1; attempt <= 12; attempt++) { + const keysRequest = HttpClientRequest.get( + `${stagingApiUrl}/v1/projects/${projectRef}/api-keys`, + ).pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${ACCESS_TOKEN}`)); + const keysResponse = yield* HttpClient.execute(keysRequest); + if (keysResponse.status >= 200 && keysResponse.status < 300) { + const keys = + yield* HttpClientResponse.schemaBodyJson(serviceRoleKeysSchema)(keysResponse); + serviceRoleKey = keys.find((key) => key.name === "service_role")?.api_key ?? ""; + break; + } + if (attempt === 12) { + throw new Error( + `Failed to fetch api-keys after 12 attempts: ${yield* keysResponse.text}`, + ); + } + yield* Effect.sleep("10 seconds"); + } + + const storageBaseUrl = `https://${projectRef}.supabase.red`; + server.setStorageProxyUrl(storageBaseUrl); + server.setStorageProxyAuth(serviceRoleKey); + + // Create test bucket and seed a file — direct calls to staging, not via relay. + const bucketRequest = yield* HttpClientRequest.post( + `${storageBaseUrl}/storage/v1/bucket`, + ).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${serviceRoleKey}`), + HttpClientRequest.bodyJson({ + id: "cli-e2e-bucket", + name: "cli-e2e-bucket", + public: false, + }), + ); + yield* HttpClient.execute(bucketRequest); + + const objectRequest = HttpClientRequest.post( + `${storageBaseUrl}/storage/v1/object/cli-e2e-bucket/hello.txt`, + ).pipe( + HttpClientRequest.setHeader("Authorization", `Bearer ${serviceRoleKey}`), + HttpClientRequest.bodyText("hello world", "text/plain"), + ); + yield* HttpClient.execute(objectRequest); + provide("storageBucket", "cli-e2e-bucket"); + + const context = yield* Effect.context(); + return () => + Effect.runPromiseWith(context)( + Effect.gen(function* () { + // The projects:delete test is self-contained (it creates and deletes its own + // "to-delete" project). The projects:create test creates "my-project" but + // does not delete it, so we clean it up here. + yield* Effect.promise(() => cleanupProjectsByName(server.url, ["my-project"])); + yield* Effect.promise(() => deleteTestProject(server.url, projectRef)); + pgMock.stop(); + yield* Effect.promise(() => server.stop()); + }), + ); + }).pipe(Effect.provide(FetchHttpClient.layer)), ); - provide("projectRef", projectRef); - provide("orgId", orgId); - - // Wire storage proxy so /storage/v1/ calls from --local mode reach staging. - const stagingApiUrl = process.env["SUPABASE_STAGING_URL"]!; - // Wait for the project to be fully initialised before fetching api-keys — the - // api-keys endpoint is unavailable while the project is in COMING_SOON/BUILDING state. - await waitForProjectReady(stagingApiUrl, projectRef); - // Retry api-keys fetch: even after ACTIVE_HEALTHY, the endpoint may briefly return 4xx. - let serviceRoleKey = ""; - for (let attempt = 1; attempt <= 12; attempt++) { - const keysRes = await fetch(`${stagingApiUrl}/v1/projects/${projectRef}/api-keys`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (keysRes.ok) { - const keys = (await keysRes.json()) as Array<{ name: string; api_key: string }>; - serviceRoleKey = keys.find((k) => k.name === "service_role")?.api_key ?? ""; - break; - } - if (attempt === 12) { - throw new Error(`Failed to fetch api-keys after 12 attempts: ${await keysRes.text()}`); - } - await new Promise((r) => setTimeout(r, 10_000)); - } - - const storageBaseUrl = `https://${projectRef}.supabase.red`; - server.setStorageProxyUrl(storageBaseUrl); - server.setStorageProxyAuth(serviceRoleKey); - - // Create test bucket and seed a file — direct calls to staging, not via relay. - await fetch(`${storageBaseUrl}/storage/v1/bucket`, { - method: "POST", - headers: { - Authorization: `Bearer ${serviceRoleKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ id: "cli-e2e-bucket", name: "cli-e2e-bucket", public: false }), - }); - await fetch(`${storageBaseUrl}/storage/v1/object/cli-e2e-bucket/hello.txt`, { - method: "POST", - headers: { - Authorization: `Bearer ${serviceRoleKey}`, - "Content-Type": "text/plain", - }, - body: "hello world", - }); - provide("storageBucket", "cli-e2e-bucket"); - - return async () => { - // The projects:delete test is self-contained (it creates and deletes its own - // "to-delete" project). The projects:create test creates "my-project" but - // does not delete it, so we clean it up here. - await cleanupProjectsByName(server.url, ["my-project"]); - await deleteTestProject(server.url, projectRef); - pgMock.stop(); - await server.stop(); - }; } diff --git a/apps/cli-e2e/tests/staging-project.ts b/apps/cli-e2e/tests/staging-project.ts index d30d0c8bd8..f0cd491490 100644 --- a/apps/cli-e2e/tests/staging-project.ts +++ b/apps/cli-e2e/tests/staging-project.ts @@ -1,120 +1,349 @@ -import { randomBytes } from "node:crypto"; import { createHarness, exec } from "@supabase/cli-test-helpers"; -import { ACCESS_TOKEN, REGION, TARGET } from "../src/tests/env.ts"; +import { Data, Duration, Effect, Schema } from "effect"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; +import { ACCESS_TOKEN, readEnv, REGION, TARGET } from "../src/tests/env.ts"; -// Shared staging-project helpers used by record setup (tests/setup.ts). -// `apiUrl` is the replay server URL, which proxies calls to staging while -// recording. The harness target + token come from env. +// Shared staging-project helpers used by record and live setup. +const PROJECT_REF_RE = /^[a-z]{20}$/; +const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); + +const OrgSchema = Schema.Struct({ id: Schema.String }); +const ProjectSchema = Schema.Struct({ + id: Schema.optional(Schema.String), + ref: Schema.optional(Schema.String), +}); +const ProjectListEntrySchema = Schema.Struct({ + id: Schema.String, + ref: Schema.optional(Schema.String), + name: Schema.String, +}); +const ProjectStatusSchema = Schema.Struct({ status: Schema.optional(Schema.String) }); +const ApiKeySchema = Schema.Struct({ + name: Schema.optional(Schema.String), + api_key: Schema.optional(Schema.String), +}); +const PoolerConfigSchema = Schema.Struct({ + database_type: Schema.optional(Schema.String), + connection_string: Schema.optional(Schema.String), +}); +class StagingSetupError extends Data.TaggedError("StagingSetupError")<{ + readonly cause: unknown; +}> {} +const decodeOrgList = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(OrgSchema))); +const decodeProject = Schema.decodeEffect(Schema.fromJsonString(ProjectSchema)); +const decodeProjectList = Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(ProjectListEntrySchema)), +); +const decodeProjectStatus = Schema.decodeEffect(Schema.fromJsonString(ProjectStatusSchema)); +const decodeApiKeys = Schema.decodeEffect(Schema.fromJsonString(Schema.Array(ApiKeySchema))); +const decodePoolerConfig = Schema.decodeEffect( + Schema.fromJsonString(Schema.Union([PoolerConfigSchema, Schema.Array(PoolerConfigSchema)])), +); function harness(apiUrl: string) { return createHarness(TARGET, { apiUrl, accessToken: ACCESS_TOKEN }); } -const PROJECT_REF_RE = /^[a-z]{20}$/; -const TERMINAL_BAD_STATUSES = new Set(["INIT_FAILED", "RESTORE_FAILED", "REMOVED"]); +type SetupEffect = Effect.Effect< + A, + StagingSetupError | HttpClientError.HttpClientError, + HttpClient.HttpClient +>; + +function runSetup(effect: SetupEffect): Promise { + return Effect.runPromise(effect.pipe(Effect.provide(FetchHttpClient.layer), Effect.orDie)); +} + +function execCommand(apiUrl: string, args: ReadonlyArray) { + return Effect.promise(() => exec(harness(apiUrl), [...args])).pipe(Effect.orDie); +} -/** A DB password for a throwaway recording project. Randomised per call - * (overridable via CLI_E2E_DB_PASSWORD) so no static credential is committed — - * the project is deleted on teardown anyway. */ +function request( + url: string, + options: { + readonly method?: "GET" | "POST"; + readonly headers?: Readonly>; + readonly body?: unknown; + } = {}, +) { + return Effect.gen(function* () { + let req = HttpClientRequest.make(options.method ?? "GET")(url, { + headers: options.headers ?? {}, + }); + if (options.body !== undefined) { + req = yield* HttpClientRequest.bodyJson(req, options.body); + } + return yield* HttpClient.execute(req); + }).pipe(Effect.mapError((cause) => new StagingSetupError({ cause }))); +} + +/** A DB password for a throwaway project. */ export function generateDbPassword(): string { - return process.env["CLI_E2E_DB_PASSWORD"] ?? `cli-e2e-${randomBytes(12).toString("hex")}`; + const configured = readEnv("CLI_E2E_DB_PASSWORD"); + if (configured !== undefined) return configured; + const bytes = new Uint8Array(12); + crypto.getRandomValues(bytes); + return "cli-e2e-" + Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); } -export async function resolveOrgId(apiUrl: string): Promise { - const result = await exec(harness(apiUrl), ["orgs", "list", "--output", "json"]); - if (result.exitCode !== 0) throw new Error(`orgs list failed: ${result.stderr}`); - const first = (JSON.parse(result.stdout) as Array<{ id: string }>)[0]?.id; - if (!first) throw new Error("No orgs found — cannot create test project"); - return first; +export function resolveOrgId(apiUrl: string): Promise { + return runSetup( + Effect.gen(function* () { + const result = yield* execCommand(apiUrl, ["orgs", "list", "--output", "json"]); + if (result.exitCode !== 0) { + return yield* Effect.die(new Error("orgs list failed: " + result.stderr)); + } + const orgs = yield* decodeOrgList(result.stdout).pipe(Effect.orDie); + const first = orgs[0]?.id; + if (!first) return yield* Effect.die(new Error("No orgs found — cannot create test project")); + return first; + }), + ); } -export async function createTestProject( +export function createTestProject( apiUrl: string, orgId: string, name: string, password: string, ): Promise { - const result = await exec(harness(apiUrl), [ - "projects", - "create", - name, - "--org-id", - orgId, - "--db-password", - password, - "--region", - REGION, - "--output", - "json", - ]); - if (result.exitCode !== 0) throw new Error(`projects create failed: ${result.stderr}`); - const project = JSON.parse(result.stdout) as { id?: string; ref?: string }; - const ref = project.ref ?? project.id; - if (!ref || !PROJECT_REF_RE.test(ref)) { - throw new Error(`Unexpected project ref from create: ${result.stdout}`); - } - return ref; + return runSetup( + Effect.gen(function* () { + const result = yield* execCommand(apiUrl, [ + "projects", + "create", + name, + "--org-id", + orgId, + "--db-password", + password, + "--region", + REGION, + "--output", + "json", + ]); + if (result.exitCode !== 0) { + return yield* Effect.die(new Error("projects create failed: " + result.stderr)); + } + const project = yield* decodeProject(result.stdout).pipe(Effect.orDie); + const ref = project.ref ?? project.id; + if (!ref || !PROJECT_REF_RE.test(ref)) { + return yield* Effect.die(new Error("Unexpected project ref from create: " + result.stdout)); + } + return ref; + }), + ); } -// `throwOnError` surfaces deletion failures when a caller needs to fail loudly; -// record setup keeps the lenient default. -export async function deleteTestProject( +export function deleteTestProject( apiUrl: string, projectRef: string, opts: { throwOnError?: boolean } = {}, ): Promise { - try { - const result = await exec(harness(apiUrl), ["projects", "delete", projectRef, "--yes"]); - if (result.exitCode !== 0) { - throw new Error(`projects delete exited ${result.exitCode}: ${result.stderr}`); - } - } catch (err) { - if (opts.throwOnError) throw err; - console.error(`Warning: failed to delete test project ${projectRef}:`, err); - } + return runSetup( + Effect.gen(function* () { + const result = yield* execCommand(apiUrl, ["projects", "delete", projectRef, "--yes"]); + if (result.exitCode === 0) return; + if (opts.throwOnError) { + return yield* Effect.die( + new Error("projects delete exited " + result.exitCode + ": " + result.stderr), + ); + } + yield* Effect.logWarning("Warning: failed to delete test project " + projectRef); + }), + ); } -export async function cleanupProjectsByName(apiUrl: string, names: string[]): Promise { - const listResult = await exec(harness(apiUrl), ["projects", "list", "--output", "json"]); - if (listResult.exitCode !== 0) return; +export function cleanupProjectsByName(apiUrl: string, names: string[]): Promise { + return runSetup( + Effect.gen(function* () { + const listResult = yield* execCommand(apiUrl, ["projects", "list", "--output", "json"]); + if (listResult.exitCode !== 0) return; + const projects = yield* decodeProjectList(listResult.stdout).pipe(Effect.orDie); + for (const project of projects.filter((entry) => names.includes(entry.name))) { + const ref = project.ref ?? project.id; + if (PROJECT_REF_RE.test(ref)) { + yield* execCommand(apiUrl, ["projects", "delete", ref, "--yes"]); + } + } + }), + ); +} - const projects = JSON.parse(listResult.stdout) as Array<{ - id: string; - ref?: string; - name: string; - }>; +/** Poll until the project is ACTIVE_HEALTHY. */ +export function waitForProjectReady( + apiBaseUrl: string, + projectRef: string, + timeoutMs = 300_000, +): Promise { + return runSetup( + Effect.gen(function* () { + const deadline = (yield* Effect.clockWith((clock) => clock.currentTimeMillis)) + timeoutMs; + while ((yield* Effect.clockWith((clock) => clock.currentTimeMillis)) < deadline) { + const response = yield* request(apiBaseUrl + "/v1/projects/" + projectRef, { + headers: { Authorization: "Bearer " + ACCESS_TOKEN }, + }); + const body = yield* response.text; + if (response.status >= 200 && response.status < 300) { + const project = yield* decodeProjectStatus(body).pipe(Effect.orDie); + if (project.status === "ACTIVE_HEALTHY") return; + if (project.status && TERMINAL_BAD_STATUSES.has(project.status)) { + return yield* Effect.die( + new Error( + "Project " + + projectRef + + " entered terminal status " + + project.status + + " during provisioning", + ), + ); + } + } + yield* Effect.sleep(Duration.seconds(5)); + } + return yield* Effect.die( + new Error( + "Project " + projectRef + " did not become ACTIVE_HEALTHY within " + timeoutMs + "ms", + ), + ); + }), + ); +} - for (const project of projects.filter((p) => names.includes(p.name))) { - const ref = project.ref ?? project.id; - if (ref && PROJECT_REF_RE.test(ref)) { - await exec(harness(apiUrl), ["projects", "delete", ref, "--yes"]); - } - } +export function getAnonKey(apiBaseUrl: string, projectRef: string, attempts = 12): Promise { + return runSetup( + Effect.gen(function* () { + for (let attempt = 1; attempt <= attempts; attempt++) { + const response = yield* request(apiBaseUrl + "/v1/projects/" + projectRef + "/api-keys", { + headers: { Authorization: "Bearer " + ACCESS_TOKEN }, + }); + const body = yield* response.text; + if (response.status >= 200 && response.status < 300) { + const keys = yield* decodeApiKeys(body).pipe(Effect.orDie); + const anon = keys.find((key) => key.name === "anon" && key.api_key)?.api_key; + if (anon) return anon; + if (keys.length > 0) { + return yield* Effect.die(new Error("Project " + projectRef + " returned no anon JWT")); + } + } + if (attempt === attempts) { + return yield* Effect.die( + new Error( + "Failed to resolve anon key for " + projectRef + " after " + attempts + " attempts", + ), + ); + } + yield* Effect.sleep(Duration.seconds(10)); + } + return yield* Effect.die(new Error("Failed to resolve anon key for " + projectRef)); + }), + ); } -/** Poll the Management API until the recording project is ACTIVE_HEALTHY. */ -export async function waitForProjectReady( +export function getServiceRoleKey( apiBaseUrl: string, projectRef: string, - timeoutMs = 300_000, + attempts = 12, +): Promise { + return runSetup( + Effect.gen(function* () { + for (let attempt = 1; attempt <= attempts; attempt++) { + const response = yield* request(apiBaseUrl + "/v1/projects/" + projectRef + "/api-keys", { + headers: { Authorization: "Bearer " + ACCESS_TOKEN }, + }); + const body = yield* response.text; + if (response.status >= 200 && response.status < 300) { + const keys = yield* decodeApiKeys(body).pipe(Effect.orDie); + const secret = + keys.find((key) => key.name === "service_role" && key.api_key)?.api_key ?? + keys.find((key) => key.api_key?.startsWith("sb_secret_"))?.api_key; + if (secret) return secret; + } + if (attempt === attempts) { + return yield* Effect.die( + new Error("Failed to resolve service-role key for " + projectRef), + ); + } + yield* Effect.sleep(Duration.seconds(10)); + } + return yield* Effect.die(new Error("Failed to resolve service-role key for " + projectRef)); + }), + ); +} + +export function createStorageBucket( + projectHost: string, + projectRef: string, + serviceRoleKey: string, + bucket: string, ): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const res = await fetch(`${apiBaseUrl}/v1/projects/${projectRef}`, { - headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, - }); - if (res.ok) { - const project = (await res.json()) as { status?: string }; - if (project.status === "ACTIVE_HEALTHY") return; - if (project.status && TERMINAL_BAD_STATUSES.has(project.status)) { - throw new Error( - `Project ${projectRef} entered terminal status ${project.status} during provisioning`, + return runSetup( + Effect.gen(function* () { + const response = yield* request( + "https://" + projectRef + "." + projectHost + "/storage/v1/bucket", + { + method: "POST", + headers: { + Authorization: "Bearer " + serviceRoleKey, + "Content-Type": "application/json", + }, + body: { id: bucket, name: bucket, public: false }, + }, + ); + const body = yield* response.text; + if ((response.status < 200 || response.status >= 300) && response.status !== 409) { + return yield* Effect.die( + new Error("Failed to create bucket " + bucket + ": " + response.status + " " + body), ); } - } else { - await res.body?.cancel(); - } - await new Promise((r) => setTimeout(r, 5_000)); - } - throw new Error(`Project ${projectRef} did not become ACTIVE_HEALTHY within ${timeoutMs}ms`); + }), + ); +} + +export function getPoolerSessionUrl( + apiBaseUrl: string, + projectRef: string, + password: string, + attempts = 12, +): Promise { + return runSetup( + Effect.gen(function* () { + for (let attempt = 1; attempt <= attempts; attempt++) { + const response = yield* request( + apiBaseUrl + "/v1/projects/" + projectRef + "/config/database/pooler", + { headers: { Authorization: "Bearer " + ACCESS_TOKEN } }, + ); + const body = yield* response.text; + if (response.status >= 200 && response.status < 300) { + const payload = yield* decodePoolerConfig(body).pipe(Effect.orDie); + const configs = Array.isArray(payload) ? payload : [payload]; + const primary = + configs.find((config) => config.database_type === "PRIMARY") ?? configs[0]; + if (primary?.connection_string) { + const url = new URL(primary.connection_string); + url.password = password; + url.port = "5432"; + if (!url.searchParams.has("connect_timeout")) { + url.searchParams.set("connect_timeout", "30"); + } + return url.toString(); + } + } + if (attempt === attempts) { + return yield* Effect.die( + new Error( + "Failed to resolve pooler config for " + + projectRef + + " after " + + attempts + + " attempts", + ), + ); + } + yield* Effect.sleep(Duration.seconds(10)); + } + return yield* Effect.die(new Error("Failed to resolve pooler config for " + projectRef)); + }), + ); } diff --git a/apps/cli-e2e/vitest.config.ts b/apps/cli-e2e/vitest.config.ts index bb87f884aa..7a876e3529 100644 --- a/apps/cli-e2e/vitest.config.ts +++ b/apps/cli-e2e/vitest.config.ts @@ -13,8 +13,8 @@ export default defineConfig({ hookTimeout: 30_000, sequence: { sequencer: class extends BaseSequencer { - override async sort(files: TestSpecification[]) { - return [...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId)); + override sort(files: TestSpecification[]) { + return Promise.resolve([...files].sort((a, b) => a.moduleId.localeCompare(b.moduleId))); } }, }, diff --git a/apps/cli/package.json b/apps/cli/package.json index 907b71eaf7..4c86881bb5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -49,6 +49,7 @@ "@clack/prompts": "^1.7.0", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", + "@effect/platform-node": "catalog:", "@effect/sql-pg": "catalog:", "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/apps/cli/scripts/apply-release-notes.ts b/apps/cli/scripts/apply-release-notes.ts index 9027208231..367ae14ced 100644 --- a/apps/cli/scripts/apply-release-notes.ts +++ b/apps/cli/scripts/apply-release-notes.ts @@ -1,15 +1,35 @@ #!/usr/bin/env bun -// Push the contents of release-notes/v.md to the GitHub Release body -// for tag v. Invoked from apply-release-notes.yml after a -// release-notes PR is merged to main. -// -// Usage: -// bun apps/cli/scripts/apply-release-notes.ts --tag v2.101.0 +// Push the contents of release-notes/v.md to the GitHub Release body. import { $ } from "bun"; -import { existsSync } from "node:fs"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem } from "effect"; +import * as EffectPath from "effect/Path"; + +class ApplyReleaseNotesError extends Data.TaggedError("ApplyReleaseNotesError")<{ + readonly operation: string; + readonly cause: string; + readonly exitCode?: number; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const errorMessage = (error: unknown) => + error instanceof ApplyReleaseNotesError + ? `${error.operation}: ${error.cause}` + : error instanceof Error + ? error.message + : String(error); + +const runShell = (operation: string, command: () => PromiseLike) => + Effect.tryPromise({ + try: command, + catch: (cause) => new ApplyReleaseNotesError({ operation, cause: causeMessage(cause) }), + }); +const logError = (message: string) => + Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); const { values } = parseArgs({ options: { @@ -18,20 +38,41 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.101.0)"); - process.exit(2); -} -const version = tag.replace(/^v/, ""); - -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`); -if (!existsSync(notesPath)) { - console.error(`No notes file at ${path.relative(repoRoot, notesPath)}`); - process.exit(1); -} +const main = Effect.gen(function* () { + const path = yield* EffectPath.Path; + const fileSystem = yield* FileSystem.FileSystem; + const tag = values.tag; + if (!tag) { + return yield* new ApplyReleaseNotesError({ + operation: "validate --tag", + cause: "--tag is required (e.g. --tag v2.101.0)", + exitCode: 2, + }); + } + const version = tag.replace(/^v/, ""); + const repoRoot = (yield* runShell("git rev-parse", () => + $`git rev-parse --show-toplevel`.text(), + )).trim(); + const notesPath = path.join(repoRoot, "release-notes", `v${version}.md`); + if (!(yield* fileSystem.exists(notesPath))) { + return yield* new ApplyReleaseNotesError({ + operation: "check release notes", + cause: `No notes file at ${path.relative(repoRoot, notesPath)}`, + }); + } + yield* logError(`==> Updating GitHub Release body for ${tag}`); + yield* runShell("gh release edit", () => + $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot), + ); + yield* logError("==> Done"); +}); -console.error(`==> Updating GitHub Release body for ${tag}`); -await $`gh release edit ${tag} --notes-file ${notesPath}`.cwd(repoRoot); -console.error(`==> Done`); +Effect.runPromise(main.pipe(Effect.provide(BunServices.layer))).then( + () => { + process.exitCode = 0; + }, + (error: unknown) => { + Effect.runSync(logError(errorMessage(error))); + process.exitCode = error instanceof ApplyReleaseNotesError ? (error.exitCode ?? 1) : 1; + }, +); diff --git a/apps/cli/scripts/backfill-release-notes.ts b/apps/cli/scripts/backfill-release-notes.ts index 139db13fa3..59fa624568 100644 --- a/apps/cli/scripts/backfill-release-notes.ts +++ b/apps/cli/scripts/backfill-release-notes.ts @@ -1,33 +1,80 @@ #!/usr/bin/env bun -// Re-derive a GitHub Release's changelog from its tag's commit using the -// *current* semantic-release config, regardless of what apps/cli/package.json -// looked like when the tag was cut. Used both as a local debugging tool and -// as the engine behind .github/workflows/backfill-release-notes.yml. -// -// Why every step matters: when backfilling an old tag, semantic-release -// trips on several things at once - it picks the wrong branch from CI env -// vars, can't read channel notes for historical tags, refuses to proceed if -// the local branch is "behind" the real remote, and uses whatever -// release.branches/plugins config existed at the tag's commit (which on -// this repo pre-dates the `channel: "beta"` fix from commit 2515885 and -// the release-notes-generator plugin from #5316). The script works around -// each of those in a temp clone so the original workspace stays clean. -// -// Usage: -// bun apps/cli/scripts/backfill-release-notes.ts --tag v2.99.0-beta.1 -// bun apps/cli/scripts/backfill-release-notes.ts --tag v2.100.1 --apply -// -// --tag Required. Release tag to refresh (e.g. v2.99.0-beta.1). -// --apply Update the GitHub Release body via `gh release edit`. -// Without it, raw markdown notes are printed to stdout. +// Re-derive a GitHub Release's changelog from a historical tag using the +// current semantic-release configuration. import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; -import semanticRelease from "semantic-release"; +import { BunServices } from "@effect/platform-bun"; +import { ConfigProvider, Data, Effect, FileSystem, Schema } from "effect"; +import * as EffectPath from "effect/Path"; +import semanticRelease, { type Result as SemanticReleaseResult } from "semantic-release"; +class BackfillError extends Data.TaggedError("BackfillError")<{ + readonly operation: string; + readonly cause: string; + readonly exitCode?: number; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const errorMessage = (error: unknown) => + error instanceof BackfillError + ? `${error.operation}: ${error.cause}` + : error instanceof Error + ? error.message + : String(error); + +const runShell = ( + operation: string, + command: () => PromiseLike, +): Effect.Effect => + Effect.tryPromise({ + try: command, + catch: (cause) => new BackfillError({ operation, cause: causeMessage(cause) }), + }); + +const runForeign = ( + operation: string, + operationEffect: () => PromiseLike, +): Effect.Effect => + Effect.tryPromise({ + try: operationEffect, + catch: (cause) => new BackfillError({ operation, cause: causeMessage(cause) }), + }); +const logError = (message: string) => + Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); + +const collectEnvironment = ( + provider: ConfigProvider.ConfigProvider, + path: ConfigProvider.Path = [], +): Effect.Effect, ConfigProvider.SourceError> => + Effect.gen(function* () { + const node = yield* provider.load(path); + if (node === undefined) return []; + const prefix = path.join("_"); + const entry = (value: string): readonly [string, string] => [prefix, value]; + const own = node._tag === "Value" || node.value === undefined ? [] : [entry(node.value)]; + if (node._tag === "Value") return [entry(node.value)]; + const children = + node._tag === "Record" + ? [...node.keys] + : Array.from({ length: node.length }, (_, index) => String(index)); + const nested = yield* Effect.forEach( + children, + (child) => collectEnvironment(provider, [...path, child]), + { + concurrency: "unbounded", + }, + ); + return [...own, ...nested.flat()]; + }); + +const PackageJsonSchema = Schema.Record(Schema.String, Schema.Json); +const ReleaseNotePayload = Schema.Struct({ channels: Schema.Array(Schema.String) }); +type PackageJson = Schema.Schema.Type; +const isJsonObject = (value: Schema.Json): value is Schema.JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); const { values } = parseArgs({ options: { tag: { type: "string" }, @@ -36,201 +83,242 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.99.0-beta.1)"); - process.exit(2); -} -const apply = values.apply ?? false; - -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const cliDir = path.join(repoRoot, "apps/cli"); - -const rootPkg = JSON.parse(await readFile(path.join(cliDir, "package.json"), "utf8")); -const repoField = rootPkg.repository?.url ?? rootPkg.repository ?? ""; -const repoUrl = `${String(repoField) - .replace(/^git\+/, "") - .replace(/\.git$/, "") - .replace(/\/$/, "")}.git`; -if (!repoUrl.startsWith("http")) { - console.error(`Could not derive repository URL from apps/cli/package.json (got: ${repoUrl})`); - process.exit(1); -} - -const tagCheck = await $`git rev-parse -q --verify refs/tags/${tag}` - .cwd(repoRoot) - .nothrow() - .quiet(); -if (tagCheck.exitCode !== 0) { - console.error(`Tag ${tag} not found locally. Try: git fetch --tags origin`); - process.exit(1); -} - -const branch = tag.includes("-beta.") ? "develop" : "main"; - -const work = await mkdtemp(path.join(tmpdir(), "backfill-release-notes.")); -const clone = path.join(work, "repo"); - -try { - console.error(`==> Cloning ${repoRoot} -> ${clone}`); - await $`git clone --quiet --no-local ${repoRoot} ${clone}`; - - // `git notes add` (used below to seed channel notes) requires a committer - // identity. CI runners don't ship one in ~/.gitconfig, so without this the - // seeding loop silently fails - semantic-release then can't see prior beta - // tags on the beta channel and computes the wrong next version. - await $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`; - await $`git -C ${clone} config --local user.name backfill-release-notes`; - // Same reason - and `commit.gpgsign`/`tag.gpgsign` inherited from a user's - // global config would make `git notes add` fail in environments without a - // signing key. The temp clone never publishes anything, so disable signing. - await $`git -C ${clone} config --local commit.gpgsign false`; - await $`git -C ${clone} config --local tag.gpgsign false`; - - const originUrl = (await $`git -C ${repoRoot} remote get-url origin`.text()).trim(); - - // Notes refs aren't fetched by `git clone`. Pull from the source repo first - // (network-free), then from origin so even tags whose notes haven't been - // sync'd locally yet are available. - await $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(); - await $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/notes/*:refs/notes/*` - .nothrow() - .quiet(); - await $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/heads/main:refs/remotes/origin/main +refs/heads/develop:refs/remotes/origin/develop` - .nothrow() - .quiet(); - - const sha = (await $`git -C ${clone} rev-list -n 1 ${tag}`.text()).trim(); - // Delete the target tag *and* any other local tags pointing at the same - // commit. When a stable and a beta share a commit (e.g. v2.100.0 and - // v2.100.0-beta.2 both at 9a22aff6), semantic-release picks the higher- - // semver one as lastRelease - which becomes HEAD itself, leaving 0 - // commits and "no release". Dropping the co-incident tags lets it fall - // back to the genuine prior release on the channel. - const coincidentTagsOut = await $`git -C ${clone} tag --points-at ${sha}`.text(); - const coincidentTags = coincidentTagsOut - .split("\n") - .map((t) => t.trim()) - .filter(Boolean); - for (const ct of coincidentTags) { - await $`git -C ${clone} tag -d ${ct}`.quiet().nothrow(); - } - await $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`; - - // The clone only carries refs/heads/$BRANCH locally; seed the other - // configured branch from origin's tracking ref so semantic-release's - // branch validator sees both. - for (const cfg of ["main", "develop"]) { - if (cfg === branch) continue; - const refSha = await $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${cfg}` - .nothrow() - .quiet(); - if (refSha.exitCode === 0) { - await $`git -C ${clone} update-ref refs/heads/${cfg} ${refSha.text().trim()}`; +const main = Effect.scoped( + Effect.gen(function* () { + const path = yield* EffectPath.Path; + const fileSystem = yield* FileSystem.FileSystem; + const tag = values.tag; + if (!tag) { + return yield* new BackfillError({ + operation: "validate --tag", + cause: "--tag is required (e.g. --tag v2.99.0-beta.1)", + exitCode: 2, + }); + } + const apply = values.apply === true; + const repoRoot = (yield* runShell("git rev-parse", () => + $`git rev-parse --show-toplevel`.text(), + )).trim(); + const cliDir = path.join(repoRoot, "apps/cli"); + const decodePackage = (filePath: string): Effect.Effect => + fileSystem.readFileString(filePath, "utf8").pipe( + Effect.flatMap((contents) => + Schema.decodeEffect(Schema.fromJsonString(PackageJsonSchema))(contents), + ), + Effect.mapError( + (cause) => + new BackfillError({ operation: `read ${filePath}`, cause: causeMessage(cause) }), + ), + ); + const rootPkg = yield* decodePackage(path.join(cliDir, "package.json")); + const repoField = rootPkg.repository; + const repoUrlBase = + repoField === undefined + ? "" + : typeof repoField === "string" + ? repoField + : isJsonObject(repoField) && typeof repoField.url === "string" + ? repoField.url + : ""; + const repoUrl = `${repoUrlBase + .replace(/^git\+/, "") + .replace(/\.git$/, "") + .replace(/\/$/, "")}.git`; + if (!repoUrl.startsWith("http")) { + return yield* new BackfillError({ + operation: "derive repository URL", + cause: `Could not derive repository URL from apps/cli/package.json (got: ${repoUrl})`, + }); + } + + const tagCheck = yield* runShell("check local tag", () => + $`git rev-parse -q --verify refs/tags/${tag}`.cwd(repoRoot).nothrow().quiet(), + ); + if (tagCheck.exitCode !== 0) { + return yield* new BackfillError({ + operation: "check local tag", + cause: `Tag ${tag} not found locally. Try: git fetch --tags origin`, + }); + } + + const branch = tag.includes("-beta.") ? "develop" : "main"; + const work = yield* Effect.acquireRelease( + fileSystem.makeTempDirectory({ prefix: "backfill-release-notes." }), + (directory) => + fileSystem.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ); + const clone = path.join(work, "repo"); + yield* logError(`==> Cloning ${repoRoot} -> ${clone}`); + yield* runShell("clone repository", () => $`git clone --quiet --no-local ${repoRoot} ${clone}`); + yield* runShell( + "configure clone identity", + () => $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`, + ); + yield* runShell( + "configure clone identity", + () => $`git -C ${clone} config --local user.name backfill-release-notes`, + ); + yield* runShell( + "disable commit signing", + () => $`git -C ${clone} config --local commit.gpgsign false`, + ); + yield* runShell( + "disable tag signing", + () => $`git -C ${clone} config --local tag.gpgsign false`, + ); + + const originUrl = (yield* runShell("read origin URL", () => + $`git -C ${repoRoot} remote get-url origin`.text(), + )).trim(); + yield* runShell("fetch local notes", () => + $`git -C ${clone} fetch --no-tags --quiet ${repoRoot} +refs/notes/*:refs/notes/*` + .nothrow() + .quiet(), + ); + yield* runShell("fetch remote notes", () => + $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/notes/*:refs/notes/*` + .nothrow() + .quiet(), + ); + yield* runShell("fetch remote branches", () => + $`git -C ${clone} fetch --no-tags --quiet ${originUrl} +refs/heads/main:refs/remotes/origin/main +refs/heads/develop:refs/remotes/origin/develop` + .nothrow() + .quiet(), + ); + + const sha = (yield* runShell("resolve tag commit", () => + $`git -C ${clone} rev-list -n 1 ${tag}`.text(), + )).trim(); + const coincidentTagsOut = yield* runShell("find coincident tags", () => + $`git -C ${clone} tag --points-at ${sha}`.text(), + ); + for (const coincidentTag of coincidentTagsOut + .split("\n") + .map((value) => value.trim()) + .filter(Boolean)) { + yield* runShell("remove coincident tag", () => + $`git -C ${clone} tag -d ${coincidentTag}`.quiet().nothrow(), + ); + } + yield* runShell( + "checkout historical tag", + () => $`git -C ${clone} checkout -B ${branch} ${sha} --quiet`, + ); + + for (const configuredBranch of ["main", "develop"]) { + if (configuredBranch === branch) continue; + const refSha = yield* runShell("resolve remote branch", () => + $`git -C ${clone} rev-parse --verify -q refs/remotes/origin/${configuredBranch}` + .nothrow() + .quiet(), + ); + if (refSha.exitCode === 0) { + yield* runShell( + "seed local branch", + () => + $`git -C ${clone} update-ref refs/heads/${configuredBranch} ${refSha.text().trim()}`, + ); + } } - } - - // semantic-release's `git log --notes=refs/notes/semantic-release*` reader - // returns channels=[null] for any tag missing an annotation. With the - // current prerelease filter that drops the tag entirely, so the lastRelease - // walks past unannotated tags and ends up far enough back to drag - // unrelated commits into the changelog. Seed a channel note for every - // reachable tag that lacks one; convention is taken from the tag name. - const mergedTagsOut = await $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(); - const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag); - for (const prevTag of mergedTags) { - const noteCheck = await $`git -C ${clone} notes --ref semantic-release show ${prevTag}` - .nothrow() - .quiet(); - if (noteCheck.exitCode === 0) continue; - const channel = prevTag.includes("-beta.") - ? "beta" - : prevTag.includes("-alpha.") - ? "alpha" - : "latest"; - const payload = JSON.stringify({ channels: [channel] }); - await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(); - } - - // Apply the *current* release config to the historical checkout. Before - // commit 2515885 (May 11) the develop branch had no explicit `channel`, - // which silently broke prerelease tag matching; before #5316 the plugin - // chain didn't include release-notes-generator. Using the current config - // gives the right notes shape regardless of what shipped at the tag. - const clonePkgPath = path.join(clone, "apps/cli/package.json"); - const clonePkg = JSON.parse(await readFile(clonePkgPath, "utf8")); - clonePkg.release = rootPkg.release; - await writeFile(clonePkgPath, `${JSON.stringify(clonePkg, null, 2)}\n`); - - // semantic-release runs `git ls-remote ` and - // silently exits with "behind remote" when the remote tip differs from - // HEAD - which it always does when backfilling an old tag. Use git's - // insteadOf to redirect the real GitHub URL to the local clone for the - // duration of this run; semantic-release still treats repositoryUrl as - // the GitHub URL so commit/PR links in the rendered notes are correct. - await $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`; - - console.error(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`); - console.error(`==> Running semantic-release --dry-run`); - - // semantic-release uses env-ci to detect the current branch, which reads - // GITHUB_REF (and friends) from the GitHub Actions environment. `noCi: true` - // only bypasses the "not in CI" guard - it does not stop env-ci from - // resolving the branch from CI vars. When backfilling v2.100.1 from a - // workflow that ran on develop, env-ci returns "develop" even though the - // clone's HEAD points at main, and semantic-release then complains that - // local develop is behind remote. Strip the GitHub Actions detection vars - // so env-ci falls back to reading the branch from git HEAD in the clone. - const childEnv = { ...process.env }; - for (const key of [ - "GITHUB_ACTIONS", - "GITHUB_REF", - "GITHUB_REF_NAME", - "GITHUB_HEAD_REF", - "GITHUB_BASE_REF", - "GITHUB_EVENT_NAME", - "CI", - ]) { - delete childEnv[key]; - } - - const result = await semanticRelease( - { dryRun: true, noCi: true, repositoryUrl: repoUrl }, - { - cwd: path.join(clone, "apps/cli"), - env: childEnv, - stdout: process.stderr, - stderr: process.stderr, - }, - ); - - if (!result || !result.nextRelease) { - console.error(`semantic-release did not compute a next release for ${tag}`); - process.exit(1); - } - - const expected = tag.replace(/^v/, ""); - if (result.nextRelease.version !== expected) { - console.error( - `semantic-release computed v${result.nextRelease.version} but expected ${tag}; ` + - `check channel notes and release config`, - ); - process.exit(1); - } - - const notes = result.nextRelease.notes ?? ""; - - if (apply) { - const notesFile = path.join(work, "notes.md"); - await writeFile(notesFile, notes); - console.error(`==> Updating GitHub Release body for ${tag}`); - await $`gh release edit ${tag} --notes-file ${notesFile}`; - } else { - process.stdout.write(notes); - if (!notes.endsWith("\n")) process.stdout.write("\n"); - } -} finally { - await rm(work, { recursive: true, force: true }); -} + + const mergedTagsOut = yield* runShell("list merged tags", () => + $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(), + ); + for (const previousTag of mergedTagsOut.split("\n").filter((value) => value && value !== tag)) { + const noteCheck = yield* runShell("check release note", () => + $`git -C ${clone} notes --ref semantic-release show ${previousTag}`.nothrow().quiet(), + ); + if (noteCheck.exitCode === 0) continue; + const channel = previousTag.includes("-beta.") + ? "beta" + : previousTag.includes("-alpha.") + ? "alpha" + : "latest"; + const payload = yield* Schema.encodeEffect(Schema.fromJsonString(ReleaseNotePayload))({ + channels: [channel], + }); + yield* runShell("seed release note", () => + $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${previousTag}^{commit}`.quiet(), + ); + } + + const clonePkgPath = path.join(clone, "apps/cli/package.json"); + const clonePkg = yield* decodePackage(clonePkgPath); + const updatedPackage = { ...clonePkg, release: rootPkg.release ?? null }; + const encodedPackage = yield* Schema.encodeEffect(Schema.fromJsonString(PackageJsonSchema))( + updatedPackage, + ); + yield* fileSystem.writeFileString(clonePkgPath, `${encodedPackage}\n`); + yield* runShell( + "redirect repository URL", + () => $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`, + ); + yield* logError(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`); + yield* logError("==> Running semantic-release --dry-run"); + + const ignoredEnvironmentKeys = new Set([ + "GITHUB_ACTIONS", + "GITHUB_REF", + "GITHUB_REF_NAME", + "GITHUB_HEAD_REF", + "GITHUB_BASE_REF", + "GITHUB_EVENT_NAME", + "CI", + ]); + const environmentEntries = yield* collectEnvironment( + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), + ); + const childEnv = Object.fromEntries( + environmentEntries.filter(([key]) => !ignoredEnvironmentKeys.has(key)), + ); + const result = yield* runForeign("semantic-release", () => + semanticRelease( + { dryRun: true, noCi: true, repositoryUrl: repoUrl }, + { + cwd: path.join(clone, "apps/cli"), + env: childEnv, + stdout: process.stderr, + stderr: process.stderr, + }, + ), + ); + if (!result || !result.nextRelease) { + return yield* new BackfillError({ + operation: "semantic-release", + cause: `semantic-release did not compute a next release for ${tag}`, + }); + } + const expected = tag.replace(/^v/, ""); + if (result.nextRelease.version !== expected) { + return yield* new BackfillError({ + operation: "semantic-release", + cause: `semantic-release computed v${result.nextRelease.version} but expected ${tag}; check channel notes and release config`, + }); + } + const notes = result.nextRelease.notes ?? ""; + if (apply) { + const notesFile = path.join(work, "notes.md"); + yield* fileSystem.writeFileString(notesFile, notes); + yield* logError(`==> Updating GitHub Release body for ${tag}`); + yield* runShell( + "update GitHub release", + () => $`gh release edit ${tag} --notes-file ${notesFile}`, + ); + } else { + yield* Effect.sync(() => { + process.stdout.write(notes); + if (!notes.endsWith("\n")) process.stdout.write("\n"); + }); + } + }), +); + +Effect.runPromise(Effect.scoped(main).pipe(Effect.provide(BunServices.layer))).then( + () => { + process.exitCode = 0; + }, + (error: unknown) => { + Effect.runSync(logError(errorMessage(error))); + process.exitCode = error instanceof BackfillError ? (error.exitCode ?? 1) : 1; + }, +); diff --git a/apps/cli/scripts/build-binary.integration.test.ts b/apps/cli/scripts/build-binary.integration.test.ts index 8073bee193..f7576e3b22 100644 --- a/apps/cli/scripts/build-binary.integration.test.ts +++ b/apps/cli/scripts/build-binary.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- compiled binary tests exercise Bun subprocess and temporary host filesystem boundaries directly. import { afterEach, describe, expect, test } from "vitest"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index 024ee6b882..e464f84d1e 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -1,5 +1,6 @@ import { $ } from "bun"; import process from "node:process"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; @@ -27,7 +28,7 @@ if (packageJson.version === undefined || packageJson.version.length === 0) { } const versionDefine = `--define=SUPABASE_CLI_VERSION=${JSON.stringify(packageJson.version)}`; const defineArg = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify( - await bundleServeMainTemplate(), + await Effect.runPromise(bundleServeMainTemplate()), )}`; await $`bun build ${entrypoint} --compile ${versionDefine} ${defineArg} --outfile ${outfile}`; diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index b7a2057e48..ed438a220e 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -1,9 +1,11 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-console, effecttsgo/node-builtin-import, effecttsgo/process-env -- this executable build script is an imperative host-tool boundary. import { $ } from "bun"; import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; import { darwinBinariesForShell, MACOS_IDENTIFIERS } from "./macos-signing.ts"; @@ -95,7 +97,7 @@ const entrypoint = path.join(root, "apps/cli/src", shell, "main.ts"); const distDir = path.join(root, "dist"); const goSource = path.resolve(root, "apps/cli-go"); const serveMainTemplateDefine = `--define=SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE=${JSON.stringify( - await bundleServeMainTemplate(), + await Effect.runPromise(bundleServeMainTemplate()), )}`; const posthogBuildDefines = [ `--define=process.env.SUPABASE_CLI_POSTHOG_KEY=${JSON.stringify(process.env.POSTHOG_API_KEY ?? "")}`, diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index 44136f8caa..420a583c12 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -1,31 +1,17 @@ +#!/usr/bin/env bun // Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are // not yet present on every mirror registry and emits the missing ones as JSON. // Used by the mirror-template-images workflow to drive the backfill matrix. -// -// It checks every image and skips the ones already mirrored everywhere, so -// re-running after a successful mirror is a no-op. The exported helpers are -// unit-tested in detect-unmirrored-images.unit.test.ts; the entry block below -// (guarded by import.meta.main) performs the only side effects. -import { spawnSync } from "node:child_process"; -import { appendFileSync } from "node:fs"; -import process from "node:process"; +import { BunServices } from "@effect/platform-bun"; +import { Config, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; -/** - * Registries the mirror publishes to and the CLI pulls from, mirroring Go's - * `utils.GetRegistryImageUrls` (`defaultRegistry` + `ghcrRegistry`). An image - * counts as mirrored only when it exists on EVERY one of these — the mirror - * pushes to all of them at once, so a tag present on one but not another is a - * partial mirror that must be re-pushed. - */ +/** Registries the mirror publishes to and the CLI pulls from. */ export const MIRROR_REGISTRIES = ["public.ecr.aws", "ghcr.io"] as const; -/** - * Mirror destination for an upstream image on a single registry, mirroring Go's - * `utils.GetRegistryImageUrl` (`registry + "/supabase/" + basename`). The - * upstream org is dropped — every image is mirrored under the `supabase/` - * namespace — e.g. `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`. - */ +/** Mirror destination for an upstream image on a single registry. */ export function mirrorImageTarget(image: string, registry: string): string { const basename = image.slice(image.lastIndexOf("/") + 1); return `${registry}/supabase/${basename}`; @@ -40,66 +26,104 @@ export function mirrorImageTargets( } export interface MirrorPartition { - /** Images present on every mirror registry — nothing to do. */ readonly mirrored: ReadonlyArray; - /** Images missing from at least one mirror registry — these need backfilling. */ readonly missing: ReadonlyArray; } -/** - * Split images by whether they are fully mirrored — present on EVERY registry in - * `registries`. An image missing from any one registry lands in `missing` so the - * backfill re-pushes it everywhere. Every (image, registry) pair is queried, each - * distinct image once. No image is skipped up front — a `supabase/*` image that is - * somehow absent is reported just like a third-party one. Idempotent: once an - * image is on all registries, a re-run skips it. - */ -export async function partitionUnmirroredImages( +/** Split images by whether they are fully mirrored on every registry. */ +export function partitionUnmirroredImages( images: Iterable, - isMirrored: (target: string) => Promise, + isMirrored: (target: string) => Effect.Effect, registries: ReadonlyArray = MIRROR_REGISTRIES, -): Promise { - const unique = [...new Set(images)]; - const results = await Promise.all( - unique.map(async (image) => { - const presence = await Promise.all( - mirrorImageTargets(image, registries).map((target) => isMirrored(target)), - ); - return { image, mirrored: presence.every(Boolean) }; - }), - ); +): Effect.Effect { + return Effect.gen(function* () { + const unique = [...new Set(images)]; + const results = yield* Effect.forEach( + unique, + (image) => + Effect.gen(function* () { + const presence = yield* Effect.forEach( + mirrorImageTargets(image, registries), + isMirrored, + { concurrency: "unbounded" }, + ); + return { image, mirrored: presence.every(Boolean) }; + }), + { concurrency: "unbounded" }, + ); - return { - mirrored: results.filter((result) => result.mirrored).map((result) => result.image), - missing: results.filter((result) => !result.mirrored).map((result) => result.image), - }; + return { + mirrored: results.filter((result) => result.mirrored).map((result) => result.image), + missing: results.filter((result) => !result.mirrored).map((result) => result.image), + }; + }); } -// An image counts as mirrored only when this returns true for every registry -// target; both are queried per image by the partition above. -function imageExistsOnMirror(target: string): Promise { - const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { - stdio: "ignore", +const imageExistsOnMirror = ( + target: string, +): Effect.Effect => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const result = yield* spawner + .exitCode( + ChildProcess.make("docker", ["buildx", "imagetools", "inspect", target], { + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe(Effect.exit); + return Exit.isSuccess(result) && result.value === 0; }); - return Promise.resolve(result.status === 0); -} -if (import.meta.main) { +const writeLine = (stream: "stdout" | "stderr", message: string) => + Effect.sync(() => { + process[stream].write(`${message}\n`); + }); + +const appendOutput = (path: string, value: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const file = yield* fs.open(path, { flag: "a" }); + yield* file.writeAll(new TextEncoder().encode(value)); + }).pipe(Effect.scoped); + +const main = Effect.gen(function* () { const images = dockerfileServiceImages.map((spec) => spec.image); - const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror); + const { mirrored, missing } = yield* partitionUnmirroredImages(images, imageExistsOnMirror); - for (const image of mirrored) { - console.error(`already mirrored: ${image}`); - } - for (const image of missing) { - console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`); - } + yield* Effect.forEach(mirrored, (image) => writeLine("stderr", `already mirrored: ${image}`), { + discard: true, + }); + yield* Effect.forEach( + missing, + (image) => + writeLine("stderr", `needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`), + { discard: true }, + ); - const json = JSON.stringify(missing); - console.log(json); + const json = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Schema.String)))( + missing, + ); + yield* writeLine("stdout", json); - // Expose the list to the workflow as a step output when running in CI. - if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `missing=${json}\n`); + const githubOutput = yield* Config.option(Config.string("GITHUB_OUTPUT")); + if (Option.isSome(githubOutput)) { + yield* appendOutput(githubOutput.value, `missing=${json}\n`); } +}); + +if (import.meta.main) { + await Effect.runPromise( + main.pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ), + ), + ), + ).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); } diff --git a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts index f51420cf04..eba3c5934b 100644 --- a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; import { MIRROR_REGISTRIES, mirrorImageTarget, @@ -7,7 +8,7 @@ import { } from "./detect-unmirrored-images.ts"; describe("detect unmirrored images", () => { - test("mirrors an upstream image under the supabase namespace of a registry", () => { + it("mirrors an upstream image under the supabase namespace of a registry", () => { // Third-party orgs are dropped; only the basename is kept, matching Go's // utils.GetRegistryImageUrl. expect(mirrorImageTarget("postgrest/postgrest:v14.14", "ghcr.io")).toBe( @@ -18,7 +19,7 @@ describe("detect unmirrored images", () => { ); }); - test("targets cover every mirror registry (ECR and ghcr.io)", () => { + it("targets cover every mirror registry (ECR and ghcr.io)", () => { expect(MIRROR_REGISTRIES).toEqual(["public.ecr.aws", "ghcr.io"]); expect(mirrorImageTargets("postgrest/postgrest:v14.14")).toEqual([ "public.ecr.aws/supabase/postgrest:v14.14", @@ -26,7 +27,7 @@ describe("detect unmirrored images", () => { ]); }); - test("an image is mirrored only when present on ALL registries", async () => { + it.effect("an image is mirrored only when present on ALL registries", () => { const present = new Set([ // kong is on both registries -> mirrored. "public.ecr.aws/supabase/kong:2.8.1", @@ -37,29 +38,33 @@ describe("detect unmirrored images", () => { const queried: string[] = []; const isMirrored = (target: string) => { queried.push(target); - return Promise.resolve(present.has(target)); + return Effect.succeed(present.has(target)); }; - const { mirrored, missing } = await partitionUnmirroredImages( - // Duplicate kong to prove de-duplication. - ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], - isMirrored, - ); + return Effect.gen(function* () { + const { mirrored, missing } = yield* partitionUnmirroredImages( + // Duplicate kong to prove de-duplication. + ["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"], + isMirrored, + ); - expect(mirrored).toEqual(["library/kong:2.8.1"]); - expect(missing).toEqual(["postgrest/postgrest:v14.14"]); - // Two unique images x two registries = four checks. - expect(queried).toHaveLength(4); + expect(mirrored).toEqual(["library/kong:2.8.1"]); + expect(missing).toEqual(["postgrest/postgrest:v14.14"]); + // Two unique images x two registries = four checks. + expect(queried).toHaveLength(4); + }); }); - test("is a no-op once everything is on every registry (idempotent re-run)", async () => { - const allMirrored = () => Promise.resolve(true); - const { mirrored, missing } = await partitionUnmirroredImages( - ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], - allMirrored, - ); + it.effect("is a no-op once everything is on every registry (idempotent re-run)", () => { + const allMirrored = () => Effect.succeed(true); + return Effect.gen(function* () { + const { mirrored, missing } = yield* partitionUnmirroredImages( + ["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"], + allMirrored, + ); - expect(missing).toEqual([]); - expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); + expect(missing).toEqual([]); + expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]); + }); }); }); diff --git a/apps/cli/scripts/generate-docs-spec.ts b/apps/cli/scripts/generate-docs-spec.ts index 8037590eca..3121a1fc8b 100644 --- a/apps/cli/scripts/generate-docs-spec.ts +++ b/apps/cli/scripts/generate-docs-spec.ts @@ -11,8 +11,9 @@ * `v` is stripped) — the workspace package.json version is a semantic-release * placeholder, so it is never used. */ -import path from "node:path"; import process from "node:process"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Effect, Path } from "effect"; import { legacyReadDocsContent } from "../src/legacy/docs/legacy-docs-spec.content.ts"; import { legacyBuildDocsSpec, @@ -26,7 +27,15 @@ function resolveVersion(): string { return argument.startsWith("v") ? argument.slice(1) : argument; } -const content = legacyReadDocsContent(path.resolve(import.meta.dir, "../docs")); +const docsRoot = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + return path.resolve(import.meta.dir, "../docs"); + }).pipe(Effect.provide(BunPath.layer)), +); +const content = await Effect.runPromise( + legacyReadDocsContent(docsRoot).pipe(Effect.provide(BunServices.layer)), +); const spec = legacyBuildDocsSpec({ root: legacyRoot, diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index a69cf9cc13..dac6f07e04 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -1,5 +1,5 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path, Schema } from "effect"; import process from "node:process"; import { PROJECT_CONFIG_SCHEMA_URL, toProjectConfigJsonSchema } from "@supabase/config"; import { nextRoot } from "../src/next/cli/root.ts"; @@ -7,13 +7,15 @@ import { collectCommands, getHelpDoc } from "../src/next/docs/command-docs.ts"; import { formatHelpDocAsMarkdown } from "../src/next/docs/markdown-formatter.ts"; const BINARY_NAME = "supabase"; -const defaultContentDir = path.resolve(import.meta.dir, "../../../apps/docs/content/docs/commands"); -const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); -const contentDir = process.argv[2] - ? path.resolve(process.cwd(), process.argv[2]) - : defaultContentDir; -function generateCommandDocs() { +const encodeJson = (value: unknown) => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 }))(value); + +const generateCommandDocs = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + contentDir: string, +) { const leaves = collectCommands(nextRoot, [BINARY_NAME]).filter( ({ command, commandPath }) => commandPath.length > 1 && command.subcommands.length === 0, ); @@ -39,11 +41,11 @@ function generateCommandDocs() { const mdxContent = `${frontmatter}\n\n${body}`; const filePath = path.join(contentDir, `${slug}.mdx`); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, mdxContent); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, mdxContent); pages.push({ slug, title, description }); - console.log(`Generated: commands/${slug}.mdx`); + yield* Effect.sync(() => process.stdout.write(`Generated: commands/${slug}.mdx\n`)); } const indexFrontmatter = [ @@ -60,28 +62,54 @@ function generateCommandDocs() { const table = `| Command | Description |\n| --- | --- |\n${rows.join("\n")}`; const indexContent = `${indexFrontmatter}\n\n${table}\n`; - writeFileSync(path.join(contentDir, "index.mdx"), indexContent); - console.log("Generated: commands/index.mdx"); + yield* fs.writeFileString(path.join(contentDir, "index.mdx"), indexContent); + yield* Effect.sync(() => process.stdout.write("Generated: commands/index.mdx\n")); const metaContent = { title: "Commands", pages: ["index", ...pages.map((page) => page.slug.split("/").pop())], }; - writeFileSync(path.join(contentDir, "meta.json"), JSON.stringify(metaContent, null, 2)); + const encodedMeta = yield* encodeJson(metaContent); + yield* fs.writeFileString(path.join(contentDir, "meta.json"), `${encodedMeta}\n`); - console.log(`\nGenerated ${pages.length} command page(s)`); -} + yield* Effect.sync(() => process.stdout.write(`\nGenerated ${pages.length} command page(s)\n`)); +}); -function generateConfigSchemaAsset() { +const generateConfigSchemaAsset = Effect.fnUntraced(function* ( + fs: FileSystem.FileSystem, + path: Path.Path, + defaultDocsPublicDir: string, +) { const schema = toProjectConfigJsonSchema(); const schemaPathname = new URL(PROJECT_CONFIG_SCHEMA_URL).pathname.replace(/^\/docs/, ""); const filePath = path.join(defaultDocsPublicDir, schemaPathname); - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(schema, null, 2)}\n`); + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + const encodedSchema = yield* encodeJson(schema); + yield* fs.writeFileString(filePath, `${encodedSchema}\n`); - console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), filePath)}`); + yield* Effect.sync(() => + process.stdout.write( + `Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), filePath)}\n`, + ), + ); +}); + +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const defaultContentDir = path.resolve( + import.meta.dir, + "../../../apps/docs/content/docs/commands", + ); + const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); + const contentDir = process.argv[2] + ? path.resolve(process.cwd(), process.argv[2]) + : defaultContentDir; + yield* generateCommandDocs(fs, path, contentDir); + yield* generateConfigSchemaAsset(fs, path, defaultDocsPublicDir); +}); + +if (import.meta.main) { + await Effect.runPromise(main.pipe(Effect.provide(BunServices.layer))); } - -generateCommandDocs(); -generateConfigSchemaAsset(); diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index 37abf57fbd..22f5c1d183 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -1,49 +1,38 @@ #!/usr/bin/env bun -// Generate a user-centric GitHub Release body for a Supabase CLI tag -// by running the Claude Agent SDK against tools/release/release-notes-prompt.md -// with the raw semantic-release block substituted in. -// -// Pipeline shape: -// 1. `backfill-release-notes.ts --tag ` produces the raw semantic-release -// markdown (without writing anything to the GH release). We always -// re-derive this so the proposer is decoupled from whatever happens to -// sit in the release body at the moment. -// 2. The raw block is inlined into tools/release/release-notes-prompt.md in -// place of the {{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} placeholder. -// 3. The Claude Agent SDK runs the rendered prompt with WebFetch + Bash so -// it can investigate PR bodies, linked issues, and changed files (the -// prompt's investigation step is real work, not boilerplate). -// 4. The agent's final assistant message is written to -// release-notes/v.md. -// 5. Unless --dry-run is passed, the script commits the file on a branch -// `release-notes/v` and opens a PR. Approving the PR (as a -// supabase/cli team member) triggers apply-release-notes.yml, which -// pushes the file's contents to the GH release body and closes the PR -// without merging. The PR targets `develop` (not `main`) so an -// accidental merge can never rewrite `main`'s history; in practice the -// file never lands on any branch. -// -// Usage: -// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --dry-run -// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --apply -// -// --tag Required. Release tag (e.g. v2.101.0 or v2.99.0-beta.1). -// --dry-run Print the proposed notes to stdout. Does not write any files, -// does not touch git. -// --apply Write release-notes/v.md, commit on a branch, push, -// and open a PR. Default behavior when neither flag is passed -// is `--dry-run`. -// --render-only Print the rendered prompt (template + raw notes block) -// and exit before any LLM call. Useful for prompt iteration -// and for verifying the pipeline shape without spending tokens. -// --model Optional. Override the Claude model (default: claude-haiku-4-5-20251001). +// Generate and optionally publish a user-facing GitHub Release body. import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { $ } from "bun"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem } from "effect"; +import * as EffectPath from "effect/Path"; + +class ProposeReleaseNotesError extends Data.TaggedError("ProposeReleaseNotesError")<{ + readonly operation: string; + readonly cause: string; + readonly exitCode?: number; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const errorMessage = (error: unknown) => + error instanceof ProposeReleaseNotesError + ? `${error.operation}: ${error.cause}` + : error instanceof Error + ? error.message + : String(error); +const runShell = ( + operation: string, + command: () => PromiseLike, +): Effect.Effect => + Effect.tryPromise({ + try: command, + catch: (cause) => new ProposeReleaseNotesError({ operation, cause: causeMessage(cause) }), + }); +const logError = (message: string) => + Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); const { values } = parseArgs({ options: { @@ -56,140 +45,167 @@ const { values } = parseArgs({ strict: true, }); -const tag = values.tag; -if (!tag) { - console.error("--tag is required (e.g. --tag v2.101.0)"); - process.exit(2); -} -const version = tag.replace(/^v/, ""); -const apply = values.apply === true && values["dry-run"] !== true; - -const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); -const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); -const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); -const notesDir = path.join(repoRoot, "release-notes"); -const notesPath = path.join(notesDir, `v${version}.md`); - -console.error(`==> Re-deriving raw semantic-release notes for ${tag}`); -const rawNotes = (await $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text()).trim(); -if (!rawNotes) { - console.error(`backfill-release-notes produced no output for ${tag}`); - process.exit(1); -} - -const promptTemplate = await readFile(promptPath, "utf8"); -const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; -if (!promptTemplate.includes(placeholder)) { - console.error(`Prompt template at ${promptPath} is missing ${placeholder}`); - process.exit(1); -} -const rendered = promptTemplate.replace(placeholder, rawNotes); - -if (values["render-only"]) { - process.stdout.write(rendered); - process.exit(0); -} - -console.error(`==> Running Claude Agent SDK (model=${values.model})`); -const options: Options = { - model: values.model, - // The agent needs WebFetch / WebSearch to investigate PR bodies and linked - // issues per the prompt's step 3, and Bash so it can use `gh` for - // authenticated GitHub queries instead of HTML scraping. Edit/Write are - // intentionally excluded — the script owns the final file output. - allowedTools: ["WebFetch", "WebSearch", "Bash"], - // Don't load the repo's CLAUDE.md or settings.json — the prompt is - // self-contained and we don't want unrelated agent context bleeding in. - settingSources: [], - cwd: repoRoot, - effort: "low", -}; - -let finalText = ""; -let cost = 0; -const stream = query({ prompt: rendered, options }); -for await (const msg of stream) { - if (msg.type === "result") { - if (msg.subtype === "success") { - finalText = msg.result; - cost = msg.total_cost_usd; - } else { - console.error(`Agent failed: ${msg.subtype}`); - if (msg.errors?.length) console.error(msg.errors.join("\n")); - process.exit(1); +const main = Effect.scoped( + Effect.gen(function* () { + const path = yield* EffectPath.Path; + const fileSystem = yield* FileSystem.FileSystem; + const tag = values.tag; + if (!tag) { + return yield* new ProposeReleaseNotesError({ + operation: "validate --tag", + cause: "--tag is required (e.g. --tag v2.101.0)", + exitCode: 2, + }); + } + const version = tag.replace(/^v/, ""); + const apply = values.apply === true && values["dry-run"] !== true; + const repoRoot = (yield* runShell("git rev-parse", () => + $`git rev-parse --show-toplevel`.text(), + )).trim(); + const promptPath = path.join(repoRoot, "tools/release/release-notes-prompt.md"); + const backfillScript = path.join(repoRoot, "apps/cli/scripts/backfill-release-notes.ts"); + const notesDir = path.join(repoRoot, "release-notes"); + const notesPath = path.join(notesDir, `v${version}.md`); + + yield* logError(`==> Re-deriving raw semantic-release notes for ${tag}`); + const rawNotes = (yield* runShell("backfill release notes", () => + $`bun ${backfillScript} --tag ${tag}`.cwd(repoRoot).text(), + )).trim(); + if (!rawNotes) { + return yield* new ProposeReleaseNotesError({ + operation: "backfill release notes", + cause: `backfill-release-notes produced no output for ${tag}`, + }); + } + const promptTemplate = yield* fileSystem.readFileString(promptPath, "utf8"); + const placeholder = "{{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}}"; + if (!promptTemplate.includes(placeholder)) { + return yield* new ProposeReleaseNotesError({ + operation: "render prompt", + cause: `Prompt template at ${promptPath} is missing ${placeholder}`, + }); + } + const rendered = promptTemplate.replace(placeholder, rawNotes); + if (values["render-only"] === true) { + yield* Effect.sync(() => process.stdout.write(rendered)); + return 0; } - } -} - -if (!finalText.trim()) { - console.error("Agent returned no result text"); - process.exit(1); -} - -// Append the raw notes to the final text to ensure the output is complete. -const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; -console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); - -if (!apply) { - process.stdout.write(normalized); - process.exit(0); -} - -await mkdir(notesDir, { recursive: true }); -if (existsSync(notesPath)) { - console.error( - `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + - `Delete it or rerun with --dry-run to preview.`, - ); - process.exit(1); -} -await writeFile(notesPath, normalized); -console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); - -const branch = `release-notes/v${version}`; -// Always cut the notes branch from origin/develop — the PR base. The workflow -// can be dispatched from an arbitrary feature branch that has diverged from -// the base by many commits; branching off the checked-out ref would drag -// every one of those commits into the PR (so the PR shows N changed files -// instead of just the proposed notes). The notes file is untracked at this -// point, so resetting HEAD to origin/develop leaves it untouched in the -// working tree. We target `develop` rather than `main` so that an accidental -// merge of this approval-only PR lands on the integration branch instead of -// rewriting `main`'s history. -await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); -await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); -await $`git add ${notesPath}`.cwd(repoRoot); -const commitMessage = `docs(release): propose user-facing notes for ${tag}`; -await $`git commit -m ${commitMessage}`.cwd(repoRoot); - -console.error(`==> Pushing ${branch}`); -let pushed = false; -for (let attempt = 0; attempt < 4; attempt++) { - const result = await $`git push -u origin ${branch}`.cwd(repoRoot).nothrow(); - if (result.exitCode === 0) { - pushed = true; - break; - } - const wait = 2 ** (attempt + 1) * 1000; - console.error(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); - await new Promise((r) => setTimeout(r, wait)); -} -if (!pushed) { - console.error("git push failed after 4 attempts"); - process.exit(1); -} -// Idempotently ensure the `do not merge` label exists on the repo, then attach -// it on PR creation. The label is a visual reminder for reviewers — the -// approval-based apply workflow never invokes the merge button — but the -// publish flow itself does not depend on it. -const labelName = "do not merge"; -await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` - .cwd(repoRoot) - .nothrow(); + yield* logError(`==> Running Claude Agent SDK (model=${values.model})`); + const options: Options = { + model: values.model, + allowedTools: ["WebFetch", "WebSearch"], + settingSources: [], + cwd: repoRoot, + effort: "low", + }; + const stream = yield* Effect.try({ + try: () => query({ prompt: rendered, options }), + catch: (cause) => + new ProposeReleaseNotesError({ + operation: "start Claude Agent SDK", + cause: causeMessage(cause), + }), + }); + const iterator = yield* Effect.acquireRelease( + Effect.try({ + try: () => stream[Symbol.asyncIterator](), + catch: (cause) => + new ProposeReleaseNotesError({ + operation: "open Claude Agent SDK stream", + cause: causeMessage(cause), + }), + }), + (agentIterator) => { + if (agentIterator.return === undefined) return Effect.void; + return runShell("close Claude Agent SDK stream", () => agentIterator.return()).pipe( + Effect.ignore, + ); + }, + ); + let finalText = ""; + let cost = 0; + while (true) { + const step = yield* runShell("read Claude Agent SDK result", () => iterator.next()); + if (step.done) break; + const message = step.value; + if (message.type !== "result") continue; + if (message.subtype === "success") { + finalText = message.result; + cost = message.total_cost_usd; + continue; + } + yield* logError(`Agent failed: ${message.subtype}`); + if (message.errors?.length) yield* logError(message.errors.join("\n")); + return yield* new ProposeReleaseNotesError({ + operation: "Claude Agent SDK", + cause: message.subtype, + }); + } + if (!finalText.trim()) { + return yield* new ProposeReleaseNotesError({ + operation: "Claude Agent SDK", + cause: "Agent returned no result text", + }); + } + const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; + yield* logError(`==> Agent finished (cost ~$${cost.toFixed(4)})`); + if (!apply) { + yield* Effect.sync(() => process.stdout.write(normalized)); + return 0; + } -const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; -const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. + yield* fileSystem.makeDirectory(notesDir, { recursive: true }); + if (yield* fileSystem.exists(notesPath)) { + return yield* new ProposeReleaseNotesError({ + operation: "write release notes", + cause: + `Refusing to overwrite existing ${path.relative(repoRoot, notesPath)}. ` + + "Delete it or rerun with --dry-run to preview.", + }); + } + yield* fileSystem.writeFileString(notesPath, normalized); + yield* logError(`==> Wrote ${path.relative(repoRoot, notesPath)}`); + + const branch = `release-notes/v${version}`; + yield* runShell("fetch develop", () => + $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(), + ); + yield* runShell("checkout release notes branch", () => + $`git checkout -B ${branch} origin/develop`.cwd(repoRoot), + ); + yield* runShell("stage release notes", () => $`git add ${notesPath}`.cwd(repoRoot)); + yield* runShell("commit release notes", () => + $`git commit -m ${`docs(release): propose user-facing notes for ${tag}`}`.cwd(repoRoot), + ); + yield* logError(`==> Pushing ${branch}`); + let pushed = false; + for (let attempt = 0; attempt < 4; attempt++) { + const result = yield* runShell("push release notes", () => + $`git push -u origin ${branch}`.cwd(repoRoot).nothrow(), + ); + if (result.exitCode === 0) { + pushed = true; + break; + } + const wait = 2 ** (attempt + 1) * 1000; + yield* logError(`Push failed (attempt ${attempt + 1}/4); retrying in ${wait / 1000}s`); + yield* Effect.sleep(wait); + } + if (!pushed) { + return yield* new ProposeReleaseNotesError({ + operation: "push release notes", + cause: "git push failed after 4 attempts", + }); + } + const labelName = "do not merge"; + yield* runShell("create release notes label", () => + $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` + .cwd(repoRoot) + .nothrow(), + ); + const releaseUrl = `https://github.com/supabase/cli/releases/tag/${tag}`; + const prBody = `Proposed user-facing release notes for \`${tag}\`, generated by \`apps/cli/scripts/propose-release-notes.ts\` against \`tools/release/release-notes-prompt.md\`. ## How to update the notes @@ -197,26 +213,26 @@ Edit \`release-notes/v${version}.md\` directly on this branch — use the GitHub ## How to publish -Approve this PR as a \`supabase/cli\` team member. The \`.github/workflows/apply-release-notes.yml\` workflow will then: - -1. Overwrite the GitHub Release body for [\`${tag}\`](${releaseUrl}) with the contents of \`release-notes/v${version}.md\`. -2. Comment the release URL on this PR. -3. Close this PR and delete the \`${branch}\` branch. - -**This PR is not merged** — the \`do not merge\` label is a reminder. It targets \`develop\` so that even an accidental merge never rewrites \`main\`. Nothing is meant to land on any branch. +Approve this PR as a \`supabase/cli\` team member. The workflow will overwrite the GitHub Release body for [\`${tag}\`](${releaseUrl}), comment the release URL on this PR, close this PR, and delete the \`${branch}\` branch. -Approvals from anyone outside the \`supabase/cli\` team are ignored; the workflow will post a comment explaining that and leave the release untouched. - -## How to abandon - -Close the PR without approving. The auto-generated semantic-release body for \`${tag}\` stays in place. - -## Re-generation - -After this PR is closed, rerun the **Propose release notes** workflow from the Actions tab against \`${tag}\` to get a fresh proposal. +**This PR is not merged** — the \`do not merge\` label is a reminder. It targets \`develop\` so an accidental merge never rewrites \`main\`. `; + yield* runShell("open release notes PR", () => + $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( + repoRoot, + ), + ); + yield* logError(`==> PR opened for ${branch}`); + return 0; + }), +); -await $`gh pr create --title ${`docs(release): notes for ${tag}`} --body ${prBody} --base develop --head ${branch} --label ${labelName}`.cwd( - repoRoot, +Effect.runPromise(main.pipe(Effect.provide(BunServices.layer))).then( + (exitCode) => { + process.exitCode = exitCode; + }, + (error: unknown) => { + Effect.runSync(logError(errorMessage(error))); + process.exitCode = error instanceof ProposeReleaseNotesError ? (error.exitCode ?? 1) : 1; + }, ); -console.error(`==> PR opened for ${branch}`); diff --git a/apps/cli/scripts/publish-docs-spec.e2e.test.ts b/apps/cli/scripts/publish-docs-spec.e2e.test.ts index 819c6b09d4..920b51ca5f 100644 --- a/apps/cli/scripts/publish-docs-spec.e2e.test.ts +++ b/apps/cli/scripts/publish-docs-spec.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- this e2e script assertion exercises the host path boundary. import path from "node:path"; import { describe, expect, it } from "vitest"; import { stringify } from "yaml"; diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index b4b969500f..1737ff0cfa 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-console, effecttsgo/node-builtin-import -- this publishing script is an imperative host-tool boundary. /** * Publishes the generated CLI reference to the docs site by opening a PR * against supabase/supabase, replacing the Go `tools/bumpdoc` that was deleted @@ -60,7 +61,7 @@ let parsed: { clispec?: unknown; info?: { version?: unknown }; commands?: unknow try { parsed = parse(spec); } catch (error) { - console.error(`Refusing to publish: stdin is not valid YAML (${error}).`); + console.error(`Refusing to publish: stdin is not valid YAML (${String(error)}).`); process.exit(1); } if (parsed?.clispec !== "001") { diff --git a/apps/cli/scripts/publish.ts b/apps/cli/scripts/publish.ts index c86c11139b..2be6156c53 100644 --- a/apps/cli/scripts/publish.ts +++ b/apps/cli/scripts/publish.ts @@ -1,10 +1,26 @@ import { $ } from "bun"; -import { copyFile } from "node:fs/promises"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; - -const root = path.resolve(import.meta.dir, "../../.."); +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Layer, Schema } from "effect"; +import * as EffectPath from "effect/Path"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; + +class PublishError extends Data.TaggedError("PublishError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +const errorMessage = (error: unknown) => + error instanceof PublishError + ? `${error.operation}: ${error.cause instanceof Error ? error.cause.message : String(error.cause)}` + : error instanceof Error + ? error.message + : String(error); +const writeStderr = (message: string) => + Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); const PLATFORM_PACKAGES = [ "cli-darwin-arm64", @@ -15,9 +31,10 @@ const PLATFORM_PACKAGES = [ "cli-linux-x64-musl", "cli-windows-arm64", "cli-windows-x64", -]; +] as const; const VALID_TAGS = new Set(["latest", "alpha", "beta"]); +const PackageJson = Schema.Struct({ name: Schema.String, version: Schema.String }); const { values } = parseArgs({ options: { @@ -26,149 +43,185 @@ const { values } = parseArgs({ }, }); -const dryRun = values["dry-run"]; -const tag = values.tag; -if (!VALID_TAGS.has(tag)) { - console.error( - `Invalid --tag value: ${String(tag)}. Expected one of: ${[...VALID_TAGS].join(", ")}.`, - ); - process.exit(1); -} - -const cliDir = path.join(root, "apps/cli"); -const cliPkgJson = await Bun.file(path.join(cliDir, "package.json")).json(); -const umbrellaName: string = cliPkgJson.name; -const umbrellaVersion: string = cliPkgJson.version; - -const dryRunFlag = dryRun ? ["--dry-run"] : []; -const tagFlag = ["--tag", tag]; -const provenanceFlag = ["--provenance"]; -const noGitChecksFlag = ["--no-git-checks"]; - -// Reads the active npm registry once. We honour `npm config get registry` -// rather than hard-coding registry.npmjs.org so the existence probe and the -// publish target stay aligned — important for the local Verdaccio harness -// (`pnpm local-registry`), which rewrites the global npm/pnpm registry config. -const registryUrl = (await $`npm config get registry`.quiet().text()).trim().replace(/\/$/, ""); - -// Probes the registry for `@`: -// 200 → already published, 404 → not, anything else throws. -// Used both as a pre-flight skip check and as a post-failure reconciliation -// when the actual publish errors with E403 (registry CDN cache may lag). -async function isAlreadyPublished(name: string, version: string): Promise { - const encodedName = name.replace("/", "%2F"); - const res = await fetch(`${registryUrl}/${encodedName}/${version}`, { method: "GET" }); - if (res.status === 200) return true; - if (res.status === 404) return false; - throw new Error(`npm registry probe for ${name}@${version} returned HTTP ${res.status}`); -} +const dryRun = values["dry-run"] === true; +const tag = values.tag ?? "latest"; + +const runShell = (operation: string, command: () => PromiseLike) => + Effect.tryPromise({ + try: command, + catch: (cause) => new PublishError({ operation, cause }), + }); type PublishResult = "published" | "skipped"; -// Publishes one workspace package idempotently. If the version is already -// on the registry — either before we start or after a publish-time conflict -// — we skip and return "skipped". Any other failure propagates. -async function publishPackage(opts: { - name: string; - version: string; - cwd: string; - extraFlags?: string[]; -}): Promise { - const { name, version, cwd, extraFlags = [] } = opts; - const label = `${name}@${version}`; - - if (await isAlreadyPublished(name, version)) { - console.log(` [skip] ${label} already published.`); - return "skipped"; - } +const main = Effect.gen(function* () { + const path = yield* EffectPath.Path; + const fileSystem = yield* FileSystem.FileSystem; + const client = yield* HttpClient.HttpClient; + const root = path.resolve(import.meta.dir, "../../.."); + const cliDir = path.join(root, "apps/cli"); + const dryRunFlag = dryRun ? ["--dry-run"] : []; + const tagFlag = ["--tag", tag]; + const provenanceFlag = ["--provenance"]; + const noGitChecksFlag = ["--no-git-checks"]; + + const log = (message: string) => + Effect.sync(() => { + process.stdout.write(`${message}\n`); + }); + const logError = (message: string) => writeStderr(message); + + const readPackageJson = (filePath: string) => + fileSystem.readFileString(filePath, "utf8").pipe( + Effect.flatMap((contents) => + Schema.decodeEffect(Schema.fromJsonString(PackageJson))(contents), + ), + Effect.mapError((cause) => new PublishError({ operation: `read ${filePath}`, cause })), + ); - console.log(` Publishing ${label}...`); - try { - await $`pnpm publish ${extraFlags} ${provenanceFlag} ${tagFlag} ${noGitChecksFlag} ${dryRunFlag}`.cwd( - cwd, + const isAlreadyPublished = (name: string, version: string) => { + const encodedName = name.replace("/", "%2F"); + const url = `${registryUrl}/${encodedName}/${version}`; + return client.execute(HttpClientRequest.get(url)).pipe( + Effect.flatMap((response) => { + if (response.status === 200) return Effect.succeed(true); + if (response.status === 404) return Effect.succeed(false); + return Effect.fail( + new PublishError({ + operation: `probe ${name}@${version}`, + cause: `HTTP ${response.status}`, + }), + ); + }), + Effect.mapError((cause) => + cause instanceof PublishError + ? cause + : new PublishError({ operation: `probe ${name}@${version}`, cause }), + ), ); - console.log(` ${label} published.`); - return "published"; - } catch (error) { - if (await isAlreadyPublished(name, version)) { - console.log( - ` [skip] ${label} reported a conflict but is now present on the registry; treating as success.`, + }; + + const publishPackage = (opts: { + readonly name: string; + readonly version: string; + readonly cwd: string; + readonly extraFlags?: ReadonlyArray; + }): Effect.Effect => { + const { name, version, cwd, extraFlags = [] } = opts; + const label = `${name}@${version}`; + return Effect.gen(function* () { + if (yield* isAlreadyPublished(name, version)) { + yield* log(` [skip] ${label} already published.`); + return "skipped" satisfies PublishResult; + } + + yield* log(` Publishing ${label}...`); + return yield* runShell(`publish ${label}`, () => + $`pnpm publish ${extraFlags} ${provenanceFlag} ${tagFlag} ${noGitChecksFlag} ${dryRunFlag}`.cwd( + cwd, + ), + ).pipe( + Effect.as("published"), + Effect.catch((error) => + isAlreadyPublished(name, version).pipe( + Effect.flatMap((alreadyPublished) => + alreadyPublished + ? log( + ` [skip] ${label} reported a conflict but is now present on the registry; treating as success.`, + ).pipe(Effect.as("skipped")) + : Effect.fail(error), + ), + ), + ), ); - return "skipped"; - } - throw error; - } -} + }); + }; + + const registryUrl = (yield* runShell("npm registry lookup", () => + $`npm config get registry`.quiet().text(), + )) + .trim() + .replace(/\/$/, ""); + const cliPackage = yield* readPackageJson(path.join(cliDir, "package.json")); + + yield* log( + dryRun + ? `Publishing to npm with tag "${tag}" (dry run)...\n` + : `Publishing to npm with tag "${tag}"...\n`, + ); -console.log( - dryRun - ? `Publishing to npm with tag "${tag}" (dry run)...\n` - : `Publishing to npm with tag "${tag}"...\n`, -); + const platformPackages = yield* Effect.forEach(PLATFORM_PACKAGES, (pkg) => + readPackageJson(path.join(root, "packages", pkg, "package.json")).pipe( + Effect.flatMap((pkgJson) => + pkgJson.version === cliPackage.version + ? Effect.succeed({ pkg, pkgJson }) + : Effect.fail( + new PublishError({ + operation: `validate ${pkg}`, + cause: `Version mismatch: @supabase/${pkg} is ${pkgJson.version}, expected ${cliPackage.version}. Run sync-versions.ts first.`, + }), + ), + ), + ), + ); -// Defensive: every platform package must already be at the umbrella version. -// `sync-versions.ts` runs in the workflow before publish (`release-shared.yml`), -// so a mismatch here means the script was invoked out of order — fail loud -// rather than publishing an inconsistent set of packages. -for (const pkg of PLATFORM_PACKAGES) { - const pkgJson = await Bun.file(path.join(root, "packages", pkg, "package.json")).json(); - if (pkgJson.version !== umbrellaVersion) { - console.error( - `Version mismatch: @supabase/${pkg} is ${pkgJson.version}, expected ${umbrellaVersion}. Run sync-versions.ts first.`, + yield* log("Publishing platform packages..."); + const platformResults = yield* Effect.forEach( + platformPackages, + ({ pkg }) => + publishPackage({ + name: `@supabase/${pkg}`, + version: cliPackage.version, + cwd: path.join(root, "packages", pkg), + extraFlags: ["--access", "public"], + }), + { concurrency: "unbounded" }, + ); + + yield* log("\nBuilding umbrella package shim..."); + yield* runShell("build umbrella shim", () => $`pnpm build:shim`.cwd(cliDir)); + yield* log("\nStaging root README for umbrella package..."); + yield* fileSystem.copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); + yield* log(`Publishing umbrella package ${cliPackage.name}...`); + const umbrellaResult = yield* publishPackage({ + name: cliPackage.name, + version: cliPackage.version, + cwd: cliDir, + }); + + const results = [...platformResults, umbrellaResult]; + const publishedCount = results.filter((result) => result === "published").length; + const skippedCount = results.filter((result) => result === "skipped").length; + yield* log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); + if (publishedCount === 0) { + yield* logError( + `\n[warn] No packages were published — every package was already on the registry at ${cliPackage.version}.\n` + + " If today's commits were expected to ship, the version did not advance.\n" + + " Re-cut as a fresh version via the Release workflow (workflow_dispatch).", ); - process.exit(1); } -} - -// Publish all platform packages in parallel -console.log("Publishing platform packages..."); -const platformResults = await Promise.all( - PLATFORM_PACKAGES.map((pkg) => - publishPackage({ - name: `@supabase/${pkg}`, - version: umbrellaVersion, - cwd: path.join(root, "packages", pkg), - extraFlags: ["--access", "public"], - }), - ), -); - -// Build the umbrella package bin shim, then publish -console.log("\nBuilding umbrella package shim..."); -await $`pnpm build:shim`.cwd(cliDir); - -// npm renders the README from the package directory on the package page. -// The workspace-internal apps/cli/README.md documents the source layout for -// contributors, which is not what we want users to see on npmjs.com. Copy -// the repo root README — the user-facing one — over it just before publish. -console.log("\nStaging root README for umbrella package..."); -await copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); - -console.log(`Publishing umbrella package ${umbrellaName}...`); -const umbrellaResult = await publishPackage({ - name: umbrellaName, - version: umbrellaVersion, - cwd: cliDir, + yield* log("\nAll packages published successfully."); + return 0; }); -const results = [...platformResults, umbrellaResult]; -const publishedCount = results.filter((r) => r === "published").length; -const skippedCount = results.filter((r) => r === "skipped").length; - -console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); - -// All-skipped is ambiguous: it can mean "recovering from a downstream-only -// failure (GH release / brew / scoop) — bytes already on npm, just continue" -// OR "semantic-release re-computed a version whose bytes are already live, so -// today's commits silently did not ship". Since we cannot tell those apart -// here, log a loud warning so the human reviewing the workflow run can decide -// whether to re-cut as a fresh version via `workflow_dispatch`. -if (publishedCount === 0) { - console.warn( - `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + - ` If today's commits were expected to ship, the version did not advance.\n` + - ` Re-cut as a fresh version via the Release workflow (workflow_dispatch).`, - ); -} +const checkedMain = + tag && VALID_TAGS.has(tag) + ? main + : Effect.fail( + new PublishError({ + operation: "validate --tag", + cause: `Invalid --tag value: ${String(tag)}. Expected one of: ${[...VALID_TAGS].join(", ")}.`, + }), + ); -console.log("\nAll packages published successfully."); +Effect.runPromise( + checkedMain.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer))), +).then( + (exitCode) => { + process.exitCode = exitCode; + }, + (error: unknown) => { + Effect.runSync(writeStderr(errorMessage(error))); + process.exitCode = 1; + }, +); diff --git a/apps/cli/scripts/sync-versions.ts b/apps/cli/scripts/sync-versions.ts index c94429bc4c..cac60a268b 100644 --- a/apps/cli/scripts/sync-versions.ts +++ b/apps/cli/scripts/sync-versions.ts @@ -1,6 +1,7 @@ -import { parseArgs } from "node:util"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path, Schema } from "effect"; import process from "node:process"; +import { parseArgs } from "node:util"; const PACKAGE_PATHS = { cli: ["apps", "cli"], @@ -24,20 +25,35 @@ const { values } = parseArgs({ const version = values.version; if (!version) { - console.error("Usage: pnpm exec bun apps/cli/scripts/sync-versions.ts --version "); + process.stderr.write( + "Usage: pnpm exec bun apps/cli/scripts/sync-versions.ts --version \n", + ); process.exit(1); } -const root = path.resolve(import.meta.dir, "../../.."); - -for (const pkg of ALL_PACKAGES) { - const pkgJsonPath = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); - const pkgJson = await Bun.file(pkgJsonPath).json(); - - pkgJson.version = version; +const packageJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); + +const main = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = path.resolve(import.meta.dir, "../../.."); + + for (const pkg of ALL_PACKAGES) { + const pkgJsonPath = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); + const pkgJson: Record = yield* Schema.decodeEffect(packageJson)( + yield* fs.readFileString(pkgJsonPath), + ); + pkgJson.version = version; + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 }))( + pkgJson, + ); + yield* fs.writeFileString(pkgJsonPath, `${encoded}\n`); + yield* Effect.sync(() => process.stdout.write(`Updated ${pkg} to v${version}\n`)); + } + + yield* Effect.sync(() => process.stdout.write(`\nAll packages synced to v${version}.\n`)); +}); - await Bun.write(pkgJsonPath, `${JSON.stringify(pkgJson, null, "\t")}\n`); - console.log(`Updated ${pkg} to v${version}`); +if (import.meta.main) { + await Effect.runPromise(main.pipe(Effect.provide(BunServices.layer))); } - -console.log(`\nAll packages synced to v${version}.`); diff --git a/apps/cli/scripts/update-homebrew.ts b/apps/cli/scripts/update-homebrew.ts index 5786f8300f..538f347c5d 100644 --- a/apps/cli/scripts/update-homebrew.ts +++ b/apps/cli/scripts/update-homebrew.ts @@ -1,10 +1,24 @@ +#!/usr/bin/env bun import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import process from "node:process"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Layer } from "effect"; +import * as EffectPath from "effect/Path"; import { parseArgs } from "node:util"; +class HomebrewUpdateError extends Data.TaggedError("HomebrewUpdateError")<{ + readonly operation: string; + readonly cause: string; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const fail = (operation: string, cause: unknown) => + new HomebrewUpdateError({ operation, cause: causeMessage(cause) }); + +const shell = (operation: string, command: () => PromiseLike) => + Effect.tryPromise({ try: command, catch: (cause) => fail(operation, cause) }); + +const output = (message: string) => Effect.sync(() => process.stdout.write(`${message}\n`)); + const { values } = parseArgs({ options: { version: { type: "string" }, @@ -18,63 +32,57 @@ const { values } = parseArgs({ const version = values.version; if (!version) { - console.error( - "Usage: bun run scripts/update-homebrew.ts --version [--repo ] [--tap ] [--name ] [--local] [--dry-run]", + process.stderr.write( + "Usage: bun run scripts/update-homebrew.ts --version [--repo ] [--tap ] [--name ] [--local] [--dry-run]\n", ); process.exit(1); } -const repo = values.repo!; -const tap = values.tap!; -const name = values.name!; -const local = values.local!; -const dryRun = values["dry-run"]!; -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -// Convert name (e.g. "supabase-beta") to the Ruby class name Homebrew -// expects (e.g. "SupabaseBeta"). The class + filename differ by channel so -// `supabase` and `supabase-beta` can coexist as separate formulas in the -// same tap, but the installed binary is always `supabase` (matching the -// Go CLI's historical behaviour). -const className = name - .split(/[-_]/) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(""); - -// `supabase-go` is the Go sidecar the legacy shell spawns via -// apps/cli/src/shared/legacy/go-proxy.layer.ts. It is looked up by exact -// filename colocated with process.execPath, so we MUST install it with its -// original name right next to the SFE. The `if File.exist?` guard makes the -// formula work for both the `legacy` shell (ships both binaries) and the -// future `next` shell (SFE only). +const repo = values.repo ?? "supabase/cli"; +const tap = values.tap ?? "supabase/homebrew-tap"; +const name = values.name ?? "supabase"; +const local = values.local ?? false; +const dryRun = values["dry-run"] ?? false; + const installBlock = [ ` bin.install "supabase"`, ` bin.install "supabase-go" if File.exist?("supabase-go")`, ].join("\n"); - const testInvocation = `#{bin}/supabase`; -// Parse checksums -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} - -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} - -const baseUrl = local - ? `file://${distDir}` - : `https://github.com/${repo}/releases/download/v${version}`; +const main = Effect.gen(function* () { + const path = yield* EffectPath.Path; + const fs = yield* FileSystem.FileSystem; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); + const checksumsText = yield* fs + .readFileString(path.join(distDir, "checksums.txt"), "utf8") + .pipe(Effect.mapError((cause) => fail("read checksums", cause))); + const checksums = new Map(); + for (const line of checksumsText.trim().split("\n")) { + const [hash, file] = line.split(/\s+/); + if (hash !== undefined && file !== undefined) checksums.set(file, hash); + } -const formula = `class ${className} < Formula + const sha = (file: string) => { + const hash = checksums.get(file); + return hash === undefined + ? Effect.fail(fail("read checksums", `Checksum not found for ${file}`)) + : Effect.succeed(hash); + }; + const className = name + .split(/[-_]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join(""); + const baseUrl = local + ? `file://${distDir}` + : `https://github.com/${repo}/releases/download/v${version}`; + const darwinArmSha = yield* sha(`supabase_${version}_darwin_arm64.tar.gz`); + const darwinX64Sha = yield* sha(`supabase_${version}_darwin_amd64.tar.gz`); + const linuxArmSha = yield* sha(`supabase_${version}_linux_arm64.tar.gz`); + const linuxX64Sha = yield* sha(`supabase_${version}_linux_amd64.tar.gz`); + const formula = `class ${className} < Formula desc "Supabase CLI" homepage "https://supabase.com" version "${version}" @@ -83,20 +91,20 @@ const formula = `class ${className} < Formula on_macos do if Hardware::CPU.arm? url "${baseUrl}/supabase_${version}_darwin_arm64.tar.gz" - sha256 "${sha(`supabase_${version}_darwin_arm64.tar.gz`)}" + sha256 "${darwinArmSha}" else url "${baseUrl}/supabase_${version}_darwin_amd64.tar.gz" - sha256 "${sha(`supabase_${version}_darwin_amd64.tar.gz`)}" + sha256 "${darwinX64Sha}" end end on_linux do if Hardware::CPU.arm? url "${baseUrl}/supabase_${version}_linux_arm64.tar.gz" - sha256 "${sha(`supabase_${version}_linux_arm64.tar.gz`)}" + sha256 "${linuxArmSha}" else url "${baseUrl}/supabase_${version}_linux_amd64.tar.gz" - sha256 "${sha(`supabase_${version}_linux_amd64.tar.gz`)}" + sha256 "${linuxX64Sha}" end end @@ -109,45 +117,62 @@ ${installBlock} end end `; + const formulaFileName = `${name}.rb`; + const formulaOut = path.join(distDir, formulaFileName); + yield* fs + .writeFileString(formulaOut, formula) + .pipe(Effect.mapError((cause) => fail("write formula", cause))); + yield* output(`Formula written to ${formulaOut}`); + + if (local || dryRun) { + yield* output(formula); + return; + } -const formulaFileName = `${name}.rb`; -const formulaOut = path.join(distDir, formulaFileName); -await writeFile(formulaOut, formula); -console.log(`Formula written to ${formulaOut}`); - -if (local || dryRun) { - console.log(formula); - process.exit(0); -} - -async function hasStagedChanges(repoDir: string, repoPath: string): Promise { - const diff = - await $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(); - if (diff.exitCode === 0) return false; - if (diff.exitCode === 1) return true; - throw new Error(`Failed to inspect staged changes for ${repoPath}`); -} + const hasStagedChanges = (repoDir: string, repoPath: string) => + shell("inspect staged changes", () => + $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(), + ).pipe( + Effect.flatMap((diff) => + diff.exitCode === 0 + ? Effect.succeed(false) + : diff.exitCode === 1 + ? Effect.succeed(true) + : Effect.fail(fail("inspect staged changes", `Failed to inspect ${repoPath}`)), + ), + ); + + const tmpDir = yield* fs + .makeTempDirectory({ prefix: "homebrew-tap-" }) + .pipe(Effect.mapError((cause) => fail("create temporary directory", cause))); + yield* Effect.gen(function* () { + const tapUrl = `https://github.com/${tap}.git`; + yield* shell("clone Homebrew tap", () => $`git clone ${tapUrl} ${tmpDir}`); + const formulaDir = path.join(tmpDir, "Formula"); + yield* fs + .makeDirectory(formulaDir, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("create formula directory", cause))); + const tapFormulaPath = path.join(formulaDir, formulaFileName); + const tapFormulaRepoPath = `Formula/${formulaFileName}`; + yield* fs + .writeFileString(tapFormulaPath, formula) + .pipe(Effect.mapError((cause) => fail("write tap formula", cause))); + yield* shell("stage formula", () => $`git -C ${tmpDir} add ${tapFormulaRepoPath}`); + if (yield* hasStagedChanges(tmpDir, tapFormulaRepoPath)) { + yield* shell("commit formula", () => $`git -C ${tmpDir} commit -m ${name + " " + version}`); + yield* shell("push formula", () => $`git -C ${tmpDir} push`); + yield* output(`Pushed formula update to ${tap}`); + } else { + yield* output(`Formula ${formulaFileName} is already up to date in ${tap}`); + } + }).pipe(Effect.ensuring(fs.remove(tmpDir, { recursive: true, force: true }).pipe(Effect.ignore))); +}); -// Clone tap repo, update formula, commit, push -const tmpDir = await mkdtemp(path.join(tmpdir(), "homebrew-tap-")); -try { - const tapUrl = `https://github.com/${tap}.git`; - await $`git clone ${tapUrl} ${tmpDir}`; - - const formulaDir = path.join(tmpDir, "Formula"); - await $`mkdir -p ${formulaDir}`; - const tapFormulaPath = path.join(formulaDir, formulaFileName); - const tapFormulaRepoPath = `Formula/${formulaFileName}`; - await writeFile(tapFormulaPath, formula); - - await $`git -C ${tmpDir} add ${tapFormulaRepoPath}`; - if (await hasStagedChanges(tmpDir, tapFormulaRepoPath)) { - await $`git -C ${tmpDir} commit -m ${name + " " + version}`; - await $`git -C ${tmpDir} push`; - console.log(`Pushed formula update to ${tap}`); - } else { - console.log(`Formula ${formulaFileName} is already up to date in ${tap}`); - } -} finally { - await rm(tmpDir, { recursive: true }); +if (import.meta.main) { + await Effect.runPromise( + main.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, BunPath.layer))), + ).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); } diff --git a/apps/cli/scripts/update-scoop.ts b/apps/cli/scripts/update-scoop.ts index c93961d5c6..380b081eb8 100644 --- a/apps/cli/scripts/update-scoop.ts +++ b/apps/cli/scripts/update-scoop.ts @@ -1,10 +1,24 @@ +#!/usr/bin/env bun import { $ } from "bun"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import process from "node:process"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Formatter, Layer } from "effect"; +import * as EffectPath from "effect/Path"; import { parseArgs } from "node:util"; +class ScoopUpdateError extends Data.TaggedError("ScoopUpdateError")<{ + readonly operation: string; + readonly cause: string; +}> {} + +const causeMessage = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const fail = (operation: string, cause: unknown) => + new ScoopUpdateError({ operation, cause: causeMessage(cause) }); + +const shell = (operation: string, command: () => PromiseLike) => + Effect.tryPromise({ try: command, catch: (cause) => fail(operation, cause) }); + +const output = (message: string) => Effect.sync(() => process.stdout.write(`${message}\n`)); + const { values } = parseArgs({ options: { version: { type: "string" }, @@ -18,126 +32,134 @@ const { values } = parseArgs({ const version = values.version; if (!version) { - console.error( - "Usage: bun run scripts/update-scoop.ts --version [--repo ] [--bucket ] [--name ] [--local] [--dry-run]", + process.stderr.write( + "Usage: bun run scripts/update-scoop.ts --version [--repo ] [--bucket ] [--name ] [--local] [--dry-run]\n", ); process.exit(1); } -const repo = values.repo!; -const bucket = values.bucket!; -const name = values.name!; -const local = values.local!; -const dryRun = values["dry-run"]!; - -// The shipped binary is always `supabase.exe`, regardless of channel — only -// the manifest filename differs (e.g. `supabase-beta.json`) so stable and -// beta can coexist in the same bucket. Matches the Go CLI's historical -// scoop-bucket layout (`supabase.json` and `supabase-beta.json` both shim -// `supabase.exe`). -const binEntry = "supabase.exe"; -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); - -// Parse checksums -const checksums = new Map(); -const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); -for (const line of checksumsText.trim().split("\n")) { - const [hash, file] = line.split(/\s+/) as [string, string]; - checksums.set(file, hash); -} +const repo = values.repo ?? "supabase/cli"; +const bucket = values.bucket ?? "supabase/scoop-bucket"; +const name = values.name ?? "supabase"; +const local = values.local ?? false; +const dryRun = values["dry-run"] ?? false; -function sha(file: string): string { - const hash = checksums.get(file); - if (!hash) throw new Error(`Checksum not found for ${file}`); - return hash; -} - -// Scoop supports file:// URLs for local testing -const baseUrl = local - ? `file:///${distDir.replace(/\\/g, "/")}` - : `https://github.com/${repo}/releases/download/v${version}`; - -// Main-bucket layout uses unversioned Windows tarballs on GitHub Releases -// (release-shared.yml copies versioned builds to supabase_windows_*.tar.gz). -// Local builds only emit versioned archives, so --local keeps those names. -const amd64Tar = local - ? `supabase_${version}_windows_amd64.tar.gz` - : "supabase_windows_amd64.tar.gz"; -const arm64Tar = local - ? `supabase_${version}_windows_arm64.tar.gz` - : "supabase_windows_arm64.tar.gz"; +const main = Effect.gen(function* () { + const path = yield* EffectPath.Path; + const fs = yield* FileSystem.FileSystem; + const root = path.resolve(import.meta.dir, "../../.."); + const distDir = path.join(root, "dist"); + const checksumsText = yield* fs + .readFileString(path.join(distDir, "checksums.txt"), "utf8") + .pipe(Effect.mapError((cause) => fail("read checksums", cause))); + const checksums = new Map(); + for (const line of checksumsText.trim().split("\n")) { + const [hash, file] = line.split(/\s+/); + if (hash !== undefined && file !== undefined) checksums.set(file, hash); + } -const manifest = { - version, - description: "Supabase CLI", - homepage: "https://supabase.com/", - license: "MIT", - architecture: { - "64bit": { - url: `${baseUrl}/${amd64Tar}`, - hash: sha(`supabase_${version}_windows_amd64.tar.gz`), - }, - arm64: { - url: `${baseUrl}/${arm64Tar}`, - hash: sha(`supabase_${version}_windows_arm64.tar.gz`), - }, - }, - bin: binEntry, - checkver: { - github: `https://github.com/${repo}`, - }, - autoupdate: { + const sha = (file: string) => { + const hash = checksums.get(file); + return hash === undefined + ? Effect.fail(fail("read checksums", `Checksum not found for ${file}`)) + : Effect.succeed(hash); + }; + const baseUrl = local + ? `file:///${distDir.replace(/\\/g, "/")}` + : `https://github.com/${repo}/releases/download/v${version}`; + const amd64Hash = yield* sha(`supabase_${version}_windows_amd64.tar.gz`); + const arm64Hash = yield* sha(`supabase_${version}_windows_arm64.tar.gz`); + const amd64Tar = local + ? `supabase_${version}_windows_amd64.tar.gz` + : "supabase_windows_amd64.tar.gz"; + const arm64Tar = local + ? `supabase_${version}_windows_arm64.tar.gz` + : "supabase_windows_arm64.tar.gz"; + const manifest = { + version, + description: "Supabase CLI", + homepage: "https://supabase.com/", + license: "MIT", architecture: { "64bit": { - url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_amd64.tar.gz`, + url: `${baseUrl}/${amd64Tar}`, + hash: amd64Hash, }, arm64: { - url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_arm64.tar.gz`, + url: `${baseUrl}/${arm64Tar}`, + hash: arm64Hash, }, }, - hash: { - url: "$baseurl/supabase_$version_checksums.txt", + bin: "supabase.exe", + checkver: { + github: `https://github.com/${repo}`, }, - }, -}; - -const manifestFileName = `${name}.json`; -const manifestJson = `${JSON.stringify(manifest, null, 4)}\n`; -const manifestOut = path.join(distDir, manifestFileName); -await writeFile(manifestOut, manifestJson); -console.log(`Manifest written to ${manifestOut}`); - -if (local || dryRun) { - console.log(manifestJson); - process.exit(0); -} + autoupdate: { + architecture: { + "64bit": { + url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_amd64.tar.gz`, + }, + arm64: { + url: `https://github.com/${repo}/releases/download/v$version/supabase_windows_arm64.tar.gz`, + }, + }, + hash: { + url: "$baseurl/supabase_$version_checksums.txt", + }, + }, + }; + const manifestFileName = `${name}.json`; + const manifestJson = `${Formatter.formatJson(manifest, { space: 4 })}\n`; + const manifestOut = path.join(distDir, manifestFileName); + yield* fs + .writeFileString(manifestOut, manifestJson) + .pipe(Effect.mapError((cause) => fail("write manifest", cause))); + yield* output(`Manifest written to ${manifestOut}`); -async function hasStagedChanges(repoDir: string, repoPath: string): Promise { - const diff = - await $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(); - if (diff.exitCode === 0) return false; - if (diff.exitCode === 1) return true; - throw new Error(`Failed to inspect staged changes for ${repoPath}`); -} + if (local || dryRun) { + yield* output(manifestJson); + return; + } -// Clone bucket repo, update manifest, commit, push -const tmpDir = await mkdtemp(path.join(tmpdir(), "scoop-bucket-")); -try { - const bucketUrl = `https://github.com/${bucket}.git`; - await $`git clone ${bucketUrl} ${tmpDir}`; + const hasStagedChanges = (repoDir: string, repoPath: string) => + shell("inspect staged changes", () => + $`git -C ${repoDir} diff --cached --quiet --exit-code -- ${repoPath}`.nothrow(), + ).pipe( + Effect.flatMap((diff) => + diff.exitCode === 0 + ? Effect.succeed(false) + : diff.exitCode === 1 + ? Effect.succeed(true) + : Effect.fail(fail("inspect staged changes", `Failed to inspect ${repoPath}`)), + ), + ); - const bucketManifestPath = path.join(tmpDir, manifestFileName); - await writeFile(bucketManifestPath, manifestJson); + const tmpDir = yield* fs + .makeTempDirectory({ prefix: "scoop-bucket-" }) + .pipe(Effect.mapError((cause) => fail("create temporary directory", cause))); + yield* Effect.gen(function* () { + const bucketUrl = `https://github.com/${bucket}.git`; + yield* shell("clone Scoop bucket", () => $`git clone ${bucketUrl} ${tmpDir}`); + const bucketManifestPath = path.join(tmpDir, manifestFileName); + yield* fs + .writeFileString(bucketManifestPath, manifestJson) + .pipe(Effect.mapError((cause) => fail("write bucket manifest", cause))); + yield* shell("stage manifest", () => $`git -C ${tmpDir} add ${manifestFileName}`); + if (yield* hasStagedChanges(tmpDir, manifestFileName)) { + yield* shell("commit manifest", () => $`git -C ${tmpDir} commit -m ${name + " " + version}`); + yield* shell("push manifest", () => $`git -C ${tmpDir} push`); + yield* output(`Pushed manifest update to ${bucket}`); + } else { + yield* output(`Manifest ${manifestFileName} is already up to date in ${bucket}`); + } + }).pipe(Effect.ensuring(fs.remove(tmpDir, { recursive: true, force: true }).pipe(Effect.ignore))); +}); - await $`git -C ${tmpDir} add ${manifestFileName}`; - if (await hasStagedChanges(tmpDir, manifestFileName)) { - await $`git -C ${tmpDir} commit -m ${name + " " + version}`; - await $`git -C ${tmpDir} push`; - console.log(`Pushed manifest update to ${bucket}`); - } else { - console.log(`Manifest ${manifestFileName} is already up to date in ${bucket}`); - } -} finally { - await rm(tmpDir, { recursive: true }); +if (import.meta.main) { + await Effect.runPromise( + main.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, BunPath.layer))), + ).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); } diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 719afd6eed..dc19ed26fb 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted, Result } from "effect"; +import { Config, Effect, FileSystem, Layer, Option, Path, Redacted, Result } from "effect"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { normalizeKeyringToken } from "../../shared/auth/keyring-token.ts"; @@ -352,7 +352,12 @@ const loadKeyringModule = ( fs: FileSystem.FileSystem, ): Effect.Effect> => Effect.gen(function* () { - const noKeyring = process.env["SUPABASE_NO_KEYRING"] === "1"; + const noKeyring = yield* Config.option(Config.string("SUPABASE_NO_KEYRING")).pipe( + Effect.map((value) => + Option.match(value, { onNone: () => false, onSome: (item) => item === "1" }), + ), + Effect.orElseSucceed(() => false), + ); const wsl = yield* detectWsl(fs); return wsl || noKeyring ? Option.none() @@ -415,6 +420,7 @@ export const legacyAccessTokenForProfile = Effect.fnUntraced(function* (profileA const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliConfig = yield* LegacyCliConfig; + const configuredHome = yield* Config.option(Config.string("SUPABASE_HOME")); // `serviceOption` keeps the logger optional (no-op outside the real CLI // tree), same as the sso pflag-reconcile module's optional services. const debugLogger: LegacyDebugLoggerShape = Option.getOrElse( @@ -440,7 +446,10 @@ export const legacyAccessTokenForProfile = Effect.fnUntraced(function* (profileA return Option.some(Redacted.make(keyringValue.value)); } - const fallbackPath = path.join(legacySupabaseHome(runtimeInfo.homeDir), "access-token"); + const fallbackPath = path.join( + legacySupabaseHome(path, Option.getOrUndefined(configuredHome), runtimeInfo.homeDir), + "access-token", + ); const fileValue = yield* readFallbackFile(fs, fallbackPath); if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); @@ -457,10 +466,15 @@ const makeLegacyCredentials = Effect.gen(function* () { const runtimeInfo = yield* RuntimeInfo; const cliConfig = yield* LegacyCliConfig; const debugLogger = yield* LegacyDebugLogger; + const configuredHome = yield* Config.option(Config.string("SUPABASE_HOME")); const profileAccount = cliConfig.profile; // /access-token — fallback file path - const fallbackDir = legacySupabaseHome(runtimeInfo.homeDir); + const fallbackDir = legacySupabaseHome( + path, + Option.getOrUndefined(configuredHome), + runtimeInfo.homeDir, + ); const fallbackPath = path.join(fallbackDir, "access-token"); const keyringModule = yield* loadKeyringModule(fs); @@ -526,12 +540,11 @@ const makeLegacyCredentials = Effect.gen(function* () { const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); if (exists) { yield* fs.remove(fallbackPath).pipe( - Effect.catch((error) => - Effect.fail( + Effect.mapError( + (error) => new LegacyDeleteTokenError({ message: `failed to remove access token file: ${error.message}`, }), - ), ), ); } @@ -546,7 +559,7 @@ const makeLegacyCredentials = Effect.gen(function* () { // No keyring backend (WSL / `SUPABASE_NO_KEYRING` / unsupported) maps // to `LegacyNotLoggedInError`. if (Option.isNone(keyringModule)) { - return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); + return yield* new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE }); } const outcome = yield* deleteProfileKeyringEntry( keyringModule.value, @@ -554,7 +567,7 @@ const makeLegacyCredentials = Effect.gen(function* () { runtimeInfo.platform, ); if (outcome === "notFound") { - return yield* Effect.fail(new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); + return yield* new LegacyNotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE }); } }), diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts index 71f1e76da2..436c70112a 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.unit.test.ts @@ -1,19 +1,18 @@ -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, FileSystem, Layer, Option, PlatformError, Redacted } from "effect"; -import { afterEach, beforeEach, vi } from "vitest"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Path, + PlatformError, + Redacted, +} from "effect"; +import { beforeEach, vi } from "vitest"; import { LegacyDebugFlag, @@ -21,6 +20,7 @@ import { LegacyWorkdirFlag, } from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { legacyCliConfigLayer } from "../config/legacy-cli-config.layer.ts"; import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { legacyCredentialsLayer } from "./legacy-credentials.layer.ts"; @@ -109,7 +109,7 @@ vi.mock("@napi-rs/keyring", () => ({ // Layer wiring -let tempHome: string; +const tempRoot = useLegacyTempWorkdir("legacy-credentials-"); function makeLayer( opts: { @@ -119,8 +119,15 @@ function makeLayer( debug?: boolean; } = {}, ) { - const home = opts.home ?? tempHome; + const home = opts.home ?? tempHome(); const env = { HOME: home, ...opts.env }; + const configLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }), + ); const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home, @@ -135,14 +142,16 @@ function makeLayer( Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(env)), ); - return legacyCredentialsLayer.pipe( + const credentialLayer = legacyCredentialsLayer.pipe( Layer.provide(cliConfigLayer), Layer.provide(legacyDebugLoggerLayer), Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(runtimeInfoLayer), Layer.provide(BunServices.layer), + Layer.provide(configLayer), Layer.provide(processEnvLayer(env)), ); + return Layer.mergeAll(credentialLayer, BunServices.layer); } beforeEach(() => { @@ -155,12 +164,36 @@ beforeEach(() => { opaqueAccounts.clear(); failDeleteAccounts.clear(); throwOnFindCredentials = false; - tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-creds-")); }); -afterEach(() => { - rmSync(tempHome, { recursive: true, force: true }); -}); +const tempHome = () => tempRoot.current; + +const tokenFile = (home: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(home, ".supabase", "access-token"); + }); + +const writeTokenFile = (home: string, token = VALID_TOKEN) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "access-token"), token, { mode: 0o600 }); + }); + +const readTokenFile = (home: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(yield* tokenFile(home)); + }); + +const tokenFileExists = (home: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(yield* tokenFile(home)); + }); const VALID_TOKEN = "sbp_" + "a".repeat(40); const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); @@ -261,10 +294,8 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }); it.effect("falls back to ~/.supabase/access-token when keyring entries miss", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 }); return Effect.gen(function* () { + yield* writeTokenFile(tempHome(), `${VALID_TOKEN}\n`); const { getAccessToken } = yield* LegacyCredentials; const token = yield* getAccessToken; expectSomeToken(token, VALID_TOKEN); @@ -272,14 +303,21 @@ describe("legacyCredentialsLayer.getAccessToken", () => { }); it.effect("falls back to SUPABASE_HOME/access-token when configured", () => { - const supabaseHome = join(tempHome, "custom-supabase-home"); - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(join(supabaseHome, "access-token"), `${VALID_TOKEN}\n`, { mode: 0o600 }); return Effect.gen(function* () { - const { getAccessToken } = yield* LegacyCredentials; - const token = yield* getAccessToken; - expectSomeToken(token, VALID_TOKEN); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); + const path = yield* Path.Path; + const supabaseHome = path.join(tempHome(), "custom-supabase-home"); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseHome, "access-token"), `${VALID_TOKEN}\n`, { + mode: 0o600, + }); + yield* Effect.gen(function* () { + expect(yield* fs.exists(path.join(supabaseHome, "access-token"))).toBe(true); + const { getAccessToken } = yield* LegacyCredentials; + const token = yield* getAccessToken; + expectSomeToken(token, VALID_TOKEN); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("returns None when no source provides a token", () => @@ -295,9 +333,9 @@ describe("legacyCredentialsLayer.getAccessToken", () => { return Effect.gen(function* () { const { getAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(getAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - const errorJson = JSON.stringify(exit.cause); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyInvalidAccessTokenError"); expect(errorJson).toContain("Invalid access token format"); } @@ -307,10 +345,8 @@ describe("legacyCredentialsLayer.getAccessToken", () => { it.effect("falls back to the filesystem when keyring throws", () => { throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), VALID_TOKEN, { mode: 0o600 }); return Effect.gen(function* () { + yield* writeTokenFile(tempHome()); const { getAccessToken } = yield* LegacyCredentials; const token = yield* getAccessToken; expectSomeToken(token, VALID_TOKEN); @@ -323,9 +359,9 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { Effect.gen(function* () { const { saveAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(saveAccessToken("nope")); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidAccessTokenError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidAccessTokenError"); } }).pipe(Effect.provide(makeLayer())), ); @@ -354,7 +390,7 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { yield* saveAccessToken(VALID_TOKEN); expect(passwords.get(goWindowsKey("supabase")) ?? "").toBe(""); expect(passwords.has("Supabase CLI/supabase")).toBe(false); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); + const content = yield* readTokenFile(tempHome()); expect(content).toBe(VALID_TOKEN); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); @@ -369,12 +405,16 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { return Effect.gen(function* () { const { saveAccessToken } = yield* LegacyCredentials; yield* saveAccessToken(VALID_TOKEN); - const fallbackDir = join(tempHome, ".supabase"); - const fallbackPath = join(fallbackDir, "access-token"); - const content = readFileSync(fallbackPath, "utf-8"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fallbackDir = path.join(tempHome(), ".supabase"); + const fallbackPath = path.join(fallbackDir, "access-token"); + const content = yield* fs.readFileString(fallbackPath); + const fallbackDirInfo = yield* fs.stat(fallbackDir); + const fallbackPathInfo = yield* fs.stat(fallbackPath); expect(content).toBe(VALID_TOKEN); - expect(statSync(fallbackDir).mode & 0o777).toBe(0o755); - expect(statSync(fallbackPath).mode & 0o777).toBe(0o600); + expect(fallbackDirInfo.mode & 0o777).toBe(0o755); + expect(fallbackPathInfo.mode & 0o777).toBe(0o600); }).pipe( Effect.provide(makeLayer()), Effect.ensuring(Effect.sync(() => process.umask(prevUmask))), @@ -383,14 +423,18 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { it.effect("filesystem fallback honors SUPABASE_HOME when configured", () => { throwOnSetPassword = true; - const supabaseHome = join(tempHome, "custom-supabase-home"); return Effect.gen(function* () { - const { saveAccessToken } = yield* LegacyCredentials; - yield* saveAccessToken(VALID_TOKEN); - const content = readFileSync(join(supabaseHome, "access-token"), "utf-8"); - expect(content).toBe(VALID_TOKEN); - expect(existsSync(join(tempHome, ".supabase", "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); + const path = yield* Path.Path; + const supabaseHome = path.join(tempHome(), "custom-supabase-home"); + yield* Effect.gen(function* () { + const { saveAccessToken } = yield* LegacyCredentials; + yield* saveAccessToken(VALID_TOKEN); + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(path.join(supabaseHome, "access-token")); + expect(content).toBe(VALID_TOKEN); + expect(yield* fs.exists(yield* tokenFile(tempHome()))).toBe(false); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); + }).pipe(Effect.provide(BunServices.layer)); }); }); @@ -398,53 +442,56 @@ describe("legacyCredentialsLayer.saveAccessToken", () => { // real failure — into the file + legacy-keyring + profile-keyring sequence. // These cases assert that ordering and tri-state exactly. describe("legacyCredentialsLayer.deleteAccessToken", () => { - const seedTokenFile = (home: string, token = VALID_TOKEN) => { - const supaDir = join(home, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), token, { mode: 0o600 }); - }; - const tokenFileExists = (home: string) => existsSync(join(home, ".supabase", "access-token")); + const seedTokenFile = writeTokenFile; it.effect("logged in via keyring profile entry → deletes file + entry, succeeds", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); passwords.set("Supabase CLI/access-token", VALID_OAUTH_TOKEN); - seedTokenFile(tempHome); return Effect.gen(function* () { + yield* seedTokenFile(tempHome()); const { deleteAccessToken } = yield* LegacyCredentials; yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); expect(passwords.has("Supabase CLI/access-token")).toBe(false); - expect(tokenFileExists(tempHome)).toBe(false); + expect(yield* tokenFileExists(tempHome())).toBe(false); }).pipe(Effect.provide(makeLayer())); }); it.effect("logged in via keyring profile entry → deletes the SUPABASE_HOME file", () => { - const supabaseHome = join(tempHome, "custom-supabase-home"); - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(join(supabaseHome, "access-token"), VALID_TOKEN, { mode: 0o600 }); passwords.set("Supabase CLI/supabase", VALID_TOKEN); return Effect.gen(function* () { - const { deleteAccessToken } = yield* LegacyCredentials; - yield* deleteAccessToken; - expect(passwords.has("Supabase CLI/supabase")).toBe(false); - expect(existsSync(join(supabaseHome, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); + const path = yield* Path.Path; + const supabaseHome = path.join(tempHome(), "custom-supabase-home"); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseHome, "access-token"), VALID_TOKEN, { + mode: 0o600, + }); + yield* Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + yield* deleteAccessToken; + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + expect(yield* fs.exists(path.join(supabaseHome, "access-token"))).toBe(false); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_HOME: supabaseHome } }))); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( "keyring profile entry absent → LegacyNotLoggedInError even though the file was removed", () => { - seedTokenFile(tempHome); return Effect.gen(function* () { + yield* seedTokenFile(tempHome()); const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); - expect(JSON.stringify(exit.cause)).toContain("You were not logged in, nothing to do."); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(Formatter.formatJson(exit.cause)).toContain( + "You were not logged in, nothing to do.", + ); } // File removal happens before the profile-keyring check (deliberate ordering). - expect(tokenFileExists(tempHome)).toBe(false); + expect(yield* tokenFileExists(tempHome())).toBe(false); }).pipe(Effect.provide(makeLayer())); }, ); @@ -453,9 +500,9 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyNotLoggedInError"); } expect(passwords.has(goWindowsKey("supabase"))).toBe(false); expect(withTargetCalls).toEqual([]); @@ -467,9 +514,9 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyNotLoggedInError"); } }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); @@ -477,15 +524,15 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { it.effect( "keyring unavailable (SUPABASE_NO_KEYRING) with token in file → removes file, still NotLoggedIn", () => { - seedTokenFile(tempHome); return Effect.gen(function* () { + yield* seedTokenFile(tempHome()); const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyNotLoggedInError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyNotLoggedInError"); } - expect(tokenFileExists(tempHome)).toBe(false); + expect(yield* tokenFileExists(tempHome())).toBe(false); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_NO_KEYRING: "1" } }))); }, ); @@ -493,57 +540,62 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { it.effect( "file remove error (non-ENOENT) → LegacyDeleteTokenError before touching keyring", () => { - const home = tempHome; - const env = { HOME: home }; - const tokenPath = join(home, ".supabase", "access-token"); // Seed a profile keyring entry to prove the keyring is never touched once // the file removal fails. passwords.set("Supabase CLI/supabase", VALID_TOKEN); - const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); - const fsLayer = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - exists: (p) => Effect.succeed(p === tokenPath), - remove: () => - Effect.fail( - PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "remove", - description: "permission denied", - pathOrDescriptor: tokenPath, - }), - ), - }), - ); - const cliConfigLayer = legacyCliConfigLayer.pipe( - Layer.provide(legacyDebugLoggerLayer), - Layer.provide(Layer.succeed(LegacyDebugFlag, false)), - Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), - Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), - Layer.provide(runtimeInfoLayer), - Layer.provide(BunServices.layer), - Layer.provide(processEnvLayer(env)), - ); - const layer = legacyCredentialsLayer.pipe( - Layer.provide(cliConfigLayer), - Layer.provide(legacyDebugLoggerLayer), - Layer.provide(Layer.succeed(LegacyDebugFlag, false)), - Layer.provide(runtimeInfoLayer), - Layer.provide(fsLayer), - Layer.provide(BunServices.layer), - Layer.provide(processEnvLayer(env)), - ); return Effect.gen(function* () { - const { deleteAccessToken } = yield* LegacyCredentials; - const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); - expect(JSON.stringify(exit.cause)).toContain("failed to remove access token file"); - } - expect(passwords.has("Supabase CLI/supabase")).toBe(true); - }).pipe(Effect.provide(layer)); + const path = yield* Path.Path; + const home = tempHome(); + const env = { HOME: home }; + const tokenPath = path.join(home, ".supabase", "access-token"); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); + const fsLayer = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (p) => Effect.succeed(p === tokenPath), + remove: () => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + description: "permission denied", + pathOrDescriptor: tokenPath, + }), + ), + }), + ); + const cliConfigLayer = legacyCliConfigLayer.pipe( + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, false)), + Layer.provide(Layer.succeed(LegacyProfileFlag, "supabase")), + Layer.provide(Layer.succeed(LegacyWorkdirFlag, Option.none())), + Layer.provide(runtimeInfoLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + const layer = legacyCredentialsLayer.pipe( + Layer.provide(cliConfigLayer), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(Layer.succeed(LegacyDebugFlag, false)), + Layer.provide(runtimeInfoLayer), + Layer.provide(fsLayer), + Layer.provide(BunServices.layer), + Layer.provide(processEnvLayer(env)), + ); + yield* Effect.gen(function* () { + const { deleteAccessToken } = yield* LegacyCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(Formatter.formatJson(exit.cause)).toContain( + "failed to remove access token file", + ); + } + expect(passwords.has("Supabase CLI/supabase")).toBe(true); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); }, ); @@ -553,10 +605,12 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); - expect(JSON.stringify(exit.cause)).toContain("failed to delete access token from keyring"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(Formatter.formatJson(exit.cause)).toContain( + "failed to delete access token from keyring", + ); } }).pipe(Effect.provide(makeLayer())); }); @@ -593,9 +647,9 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDeleteTokenError"); } expect(passwords.has(goWindowsKey("supabase"))).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); @@ -607,7 +661,7 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); @@ -619,9 +673,9 @@ describe("legacyCredentialsLayer.deleteAccessToken", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDeleteTokenError"); } }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, @@ -666,7 +720,7 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAllProjectCredentials); - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(makeLayer())); }); @@ -690,7 +744,7 @@ describe("legacyCredentialsLayer.deleteAllProjectCredentials", () => { return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteAllProjectCredentials); - expect(exit._tag).toBe("Success"); + expect(Exit.isSuccess(exit)).toBe(true); // One undecodable entry aborts the whole findCredentials call. expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); @@ -718,9 +772,9 @@ describe("legacyCredentialsLayer.deleteProjectCredential", () => { return Effect.gen(function* () { const { deleteProjectCredential } = yield* LegacyCredentials; const exit = yield* Effect.exit(deleteProjectCredential("abcdefghijklmnopqrs1")); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyCredentialDeleteError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("LegacyCredentialDeleteError"); } }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 6715bb9e11..454d4535df 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -52,17 +52,13 @@ export const legacyMakePlatformApi = Effect.gen(function* () { const authGateToken = yield* resolveAccessToken; if (Option.isNone(authGateToken)) { - return yield* Effect.fail( - new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), - ); + return yield* new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }); } yield* debugLogger.debug(`Supabase CLI ${CLI_VERSION}`); yield* debugLogger.debug(`Using profile: ${cliConfig.profile} (${cliConfig.projectHost})`); const storedToken = yield* resolveAccessToken; if (Option.isNone(storedToken)) { - return yield* Effect.fail( - new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }), - ); + return yield* new LegacyPlatformAuthRequiredError({ message: MISSING_TOKEN_MESSAGE }); } return yield* makeApiClient( diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index 9377cbd1e3..309774679a 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -1,12 +1,20 @@ +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { + DateTime, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Path, + Redacted, + Schema, +} from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { vi } from "vitest"; import { LegacyDebugFlag, LegacyDnsResolverFlag } from "../../shared/legacy/global-flags.ts"; @@ -21,9 +29,23 @@ import { legacyPlatformApiFactoryLayer } from "./legacy-platform-api-factory.lay import { LegacyPlatformApiFactory } from "./legacy-platform-api-factory.service.ts"; import { legacyPlatformApiLayer } from "./legacy-platform-api.layer.ts"; import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; const VALID_TOKEN = "sbp_" + "a".repeat(40); const SESSION_LAST_ACTIVE = 1_777_200_000_000; +const temp = useLegacyTempWorkdir("supabase-legacy-platform-api-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const TelemetryStateSchema = Schema.Struct({ + enabled: Schema.optional(Schema.Boolean), + distinct_id: Schema.optional(Schema.String), + schema_version: Schema.optional(Schema.Finite), + device_id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + session_last_active: Schema.optional(Schema.String), +}); +const encodeTelemetryState = Schema.encodeUnknownSync(Schema.fromJsonString(TelemetryStateSchema)); +const decodeTelemetryState = Schema.decodeUnknownSync(Schema.fromJsonString(TelemetryStateSchema)); function mockCliConfig(opts: { accessToken?: string; @@ -65,6 +87,7 @@ function mockTelemetryRuntime( isTty?: boolean; isCi?: boolean; debug?: boolean; + telemetryState?: { distinctId?: string; enabled?: boolean }; } = {}, ) { return Layer.succeed( @@ -110,55 +133,34 @@ function mockAnalytics() { return { layer, aliases, identifies }; } -function nodeFileSystemLayer() { - return Layer.succeed(FileSystem.FileSystem, { - [FileSystem.FileSystem.key]: FileSystem.FileSystem.key, - exists: (filePath: string) => - Effect.tryPromise(() => - access(filePath) - .then(() => true) - .catch(() => false), - ), - makeDirectory: (dirPath: string, opts?: { recursive?: boolean; mode?: number }) => - Effect.tryPromise(() => - mkdir(dirPath, { recursive: opts?.recursive, mode: opts?.mode }).then(() => undefined), - ), - readFileString: (filePath: string) => Effect.tryPromise(() => readFile(filePath, "utf8")), - writeFileString: (filePath: string, content: string, opts?: { mode?: number }) => - Effect.tryPromise(() => writeFile(filePath, content, { mode: opts?.mode })), - } as unknown as FileSystem.FileSystem); -} - -function nodePathLayer() { - return Layer.succeed(Path.Path, { - [Path.Path.key]: Path.Path.key, - ...path, - } as unknown as Path.Path); -} - -function tempTelemetryConfig(opts: { distinctId?: string; enabled?: boolean } = {}) { - const dir = mkdtempSync(path.join(tmpdir(), "supabase-legacy-platform-api-")); - writeFileSync( - path.join(dir, "telemetry.json"), - JSON.stringify({ - enabled: opts.enabled ?? true, - device_id: "device-123", - session_id: "session-123", - session_last_active: new Date(SESSION_LAST_ACTIVE).toISOString(), - ...(opts.distinctId === undefined ? {} : { distinct_id: opts.distinctId }), - schema_version: 1, - }), +function telemetryFixtureLayer( + configDir: string, + opts: { distinctId?: string; enabled?: boolean } = {}, +) { + return Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.writeFileString( + path.join(configDir, "telemetry.json"), + encodeTelemetryState({ + enabled: opts.enabled ?? true, + device_id: "device-123", + session_id: "session-123", + session_last_active: DateTime.formatIso(DateTime.makeUnsafe(SESSION_LAST_ACTIVE)), + ...(opts.distinctId === undefined ? {} : { distinct_id: opts.distinctId }), + schema_version: 1, + }), + ); + }).pipe(Effect.provide(BunServices.layer)), ); - return dir; } -function readTelemetryConfig(configDir: string) { - return JSON.parse(readFileSync(path.join(configDir, "telemetry.json"), "utf8")) as { - enabled?: boolean; - distinct_id?: string; - schema_version?: number; - }; -} +const readTelemetryConfig = (configDir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return decodeTelemetryState(yield* fs.readFileString(path.join(configDir, "telemetry.json"))); + }); function withBaseDeps( opts: { @@ -169,6 +171,7 @@ function withBaseDeps( isTty?: boolean; isCi?: boolean; debug?: boolean; + telemetryState?: { distinctId?: string; enabled?: boolean }; } = {}, ) { const analytics = opts.analytics ?? mockAnalytics(); @@ -188,17 +191,22 @@ function withBaseDeps( isCi: opts.isCi, }), ), - Layer.provide(nodeFileSystemLayer()), - Layer.provide(nodePathLayer()), + Layer.provide(BunServices.layer), ); - return (layer: Layer.Layer) => - layer.pipe( + return (layer: Layer.Layer) => { + const configured = layer.pipe( Layer.provide(identityStitch), Layer.provide(legacyDebugLoggerLayer), Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), // The lazy platform-API factory's DoH fetch layer reads the DNS-resolver flag. Layer.provide(Layer.succeed(LegacyDnsResolverFlag, "native")), ); + const withFixture = + opts.configDir !== undefined && opts.telemetryState !== undefined + ? configured.pipe(Layer.provide(telemetryFixtureLayer(opts.configDir, opts.telemetryState))) + : configured; + return Layer.mergeAll(withFixture, BunServices.layer); + }; } function captureRequests(responseHeaders: Record = {}) { @@ -211,7 +219,7 @@ function captureRequests(responseHeaders: Record = {}) { return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response(JSON.stringify([]), { + new Response(encodeJson([]), { status: 200, headers: { "content-type": "application/json", ...responseHeaders }, }), @@ -270,7 +278,7 @@ describe("legacyPlatformApiLayer", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyPlatformAuthRequiredError"); expect(errorJson).toContain("Access token not provided"); } @@ -299,7 +307,7 @@ describe("legacyPlatformApiLayer", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyInvalidAccessTokenError"); expect(errorJson).toContain("Invalid access token format"); } @@ -372,60 +380,52 @@ describe("legacyPlatformApiLayer", () => { }); it.effect("stitches identity from X-Gotrue-Id responses outside CI", () => { - const configDir = tempTelemetryConfig(); + const configDir = temp.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = legacyPlatformApiLayer.pipe( Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), - withBaseDeps({ analytics, configDir }), + withBaseDeps({ analytics, configDir, telemetryState: {} }), ); return Effect.gen(function* () { - try { - const api = yield* LegacyPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); - expect(analytics.identifies).toEqual([]); - const telemetry = readTelemetryConfig(configDir); - expect(telemetry.distinct_id).toBe("user-123"); - expect(telemetry.enabled).toBe(true); - expect(telemetry.schema_version).toBe(1); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); + expect(analytics.identifies).toEqual([]); + const telemetry = yield* readTelemetryConfig(configDir); + expect(telemetry.distinct_id).toBe("user-123"); + expect(telemetry.enabled).toBe(true); + expect(telemetry.schema_version).toBe(1); }).pipe(Effect.provide(layer)); }); it.effect("does not stitch identity from X-Gotrue-Id responses in CI", () => { - const configDir = tempTelemetryConfig(); + const configDir = temp.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = legacyPlatformApiLayer.pipe( Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), - withBaseDeps({ analytics, configDir, isCi: true }), + withBaseDeps({ analytics, configDir, isCi: true, telemetryState: {} }), ); return Effect.gen(function* () { - try { - const api = yield* LegacyPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - expect(readTelemetryConfig(configDir).distinct_id).toBeUndefined(); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect((yield* readTelemetryConfig(configDir)).distinct_id).toBeUndefined(); }).pipe(Effect.provide(layer)); }); it.effect("does not stitch identity in a first-run non-TTY runtime", () => { - const configDir = mkdtempSync(path.join(tmpdir(), "supabase-legacy-platform-api-")); + const configDir = temp.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = legacyPlatformApiLayer.pipe( @@ -436,21 +436,18 @@ describe("legacyPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* LegacyPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - expect(existsSync(path.join(configDir, "telemetry.json"))).toBe(false); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(path.join(configDir, "telemetry.json"))).toBe(false); }).pipe(Effect.provide(layer)); }); it.effect("stitches identity in a first-run TTY runtime", () => { - const configDir = mkdtempSync(path.join(tmpdir(), "supabase-legacy-platform-api-")); + const configDir = temp.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = legacyPlatformApiLayer.pipe( @@ -461,68 +458,61 @@ describe("legacyPlatformApiLayer", () => { ); return Effect.gen(function* () { - try { - const api = yield* LegacyPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); - expect(analytics.identifies).toEqual([]); - expect(readTelemetryConfig(configDir).distinct_id).toBe("user-123"); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([{ distinctId: "user-123", alias: "device-123" }]); + expect(analytics.identifies).toEqual([]); + expect((yield* readTelemetryConfig(configDir)).distinct_id).toBe("user-123"); }).pipe(Effect.provide(layer)); }); it.effect("does not stitch identity when a distinct_id is already known", () => { - const configDir = tempTelemetryConfig({ distinctId: "existing-user" }); + const configDir = temp.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = legacyPlatformApiLayer.pipe( Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), - withBaseDeps({ analytics, configDir, distinctId: "existing-user" }), + withBaseDeps({ + analytics, + configDir, + distinctId: "existing-user", + telemetryState: { distinctId: "existing-user" }, + }), ); return Effect.gen(function* () { - try { - const api = yield* LegacyPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - expect(readTelemetryConfig(configDir).distinct_id).toBe("existing-user"); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + expect((yield* readTelemetryConfig(configDir)).distinct_id).toBe("existing-user"); }).pipe(Effect.provide(layer)); }); it.effect("does not stitch identity when legacy telemetry state is disabled", () => { - const configDir = tempTelemetryConfig({ enabled: false }); + const configDir = temp.current; const analytics = mockAnalytics(); const http = captureRequests({ "X-Gotrue-Id": "user-123" }); const layer = legacyPlatformApiLayer.pipe( Layer.provide(mockCliConfig({ accessToken: VALID_TOKEN })), Layer.provide(mockCredentials(Option.none())), Layer.provide(http.layer), - withBaseDeps({ analytics, configDir }), + withBaseDeps({ analytics, configDir, telemetryState: { enabled: false } }), ); return Effect.gen(function* () { - try { - const api = yield* LegacyPlatformApi; - yield* api.v1.listAllProjects(); - - expect(analytics.aliases).toEqual([]); - expect(analytics.identifies).toEqual([]); - const telemetry = readTelemetryConfig(configDir); - expect(telemetry.enabled).toBe(false); - expect(telemetry.distinct_id).toBeUndefined(); - } finally { - rmSync(configDir, { recursive: true, force: true }); - } + const api = yield* LegacyPlatformApi; + yield* api.v1.listAllProjects(); + + expect(analytics.aliases).toEqual([]); + expect(analytics.identifies).toEqual([]); + const telemetry = yield* readTelemetryConfig(configDir); + expect(telemetry.enabled).toBe(false); + expect(telemetry.distinct_id).toBeUndefined(); }).pipe(Effect.provide(layer)); }); }); @@ -559,7 +549,7 @@ describe("legacyPlatformApiFactoryLayer (lazy token)", () => { const exit = yield* Effect.exit(factory.make); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyPlatformAuthRequiredError"); expect(errorJson).toContain("Access token not provided"); } diff --git a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts index 8cce87cbfe..4de5c6da93 100644 --- a/apps/cli/src/legacy/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/legacy/cli/agent-output.e2e.test.ts @@ -10,92 +10,80 @@ function parseJsonLines(output: string): Array { } describe("legacy CLI agent output", () => { - test("formats parse errors as JSON for detected coding agents", async () => { - const { exitCode, stdout, stderr } = await runSupabase(["definitely-not-a-command"], { + test("formats parse errors as JSON for detected coding agents", () => + runSupabase(["definitely-not-a-command"], { entrypoint: "legacy", env: { CODEX_SANDBOX: "1" }, - }); - - expect(exitCode).toBe(1); - // CLI-1901: the vendored effect CLI library's own duplicate JSON render - // (the old `{_tag:"Help"}` + `{_tag:"Error", error:{code:"ShowHelp"}}` - // pair on stdout, `{_tag:"Errors"}` on stderr) is gone. stdout carries - // exactly this repo's single Go-parity error line; the library's help - // doc is redirected to stderr instead of being dropped or duplicated. - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - _tag: "Error", - error: expect.objectContaining({ code: "UnknownSubcommand" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); - }); - - test("keeps parse errors in text mode when --output-format=text is explicit", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["--output-format", "text", "definitely-not-a-command"], - { - entrypoint: "legacy", - env: { CODEX_SANDBOX: "1" }, - }, - ); + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + // CLI-1901: the vendored effect CLI library's own duplicate JSON render + // (the old `{_tag:"Help"}` + `{_tag:"Error", error:{code:"ShowHelp"}}` + // pair on stdout, `{_tag:"Errors"}` on stderr) is gone. stdout carries + // exactly this repo's single Go-parity error line; the library's help + // doc is redirected to stderr instead of being dropped or duplicated. + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + _tag: "Error", + error: expect.objectContaining({ code: "UnknownSubcommand" }), + }), + ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); + })); - expect(exitCode).toBe(1); - // CLI-1901: the help doc no longer prints to stdout at all. - expect(stdout).toBe(""); - expect(stderr).toContain("DESCRIPTION"); - expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); - }); - - test("keeps parse errors in text mode when --agent=no is explicit", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["--agent", "no", "definitely-not-a-command"], - { - entrypoint: "legacy", - env: { CODEX_SANDBOX: "1" }, - }, - ); - - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - expect(stderr).toContain("DESCRIPTION"); - expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); - }); + test("keeps parse errors in text mode when --output-format=text is explicit", () => + runSupabase(["--output-format", "text", "definitely-not-a-command"], { + entrypoint: "legacy", + env: { CODEX_SANDBOX: "1" }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + // CLI-1901: the help doc no longer prints to stdout at all. + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); + expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); + })); - test("formats parse errors as JSON when --agent=yes is explicit", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["--agent", "yes", "definitely-not-a-command"], - { - entrypoint: "legacy", - env: {}, - }, - ); + test("keeps parse errors in text mode when --agent=no is explicit", () => + runSupabase(["--agent", "no", "definitely-not-a-command"], { + entrypoint: "legacy", + env: { CODEX_SANDBOX: "1" }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("DESCRIPTION"); + expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); + })); - expect(exitCode).toBe(1); - expect(parseJsonLines(stdout)).toEqual([ - expect.objectContaining({ - _tag: "Error", - error: expect.objectContaining({ code: "UnknownSubcommand" }), - }), - ]); - expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); - }); + test("formats parse errors as JSON when --agent=yes is explicit", () => + runSupabase(["--agent", "yes", "definitely-not-a-command"], { + entrypoint: "legacy", + env: {}, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(parseJsonLines(stdout)).toEqual([ + expect.objectContaining({ + _tag: "Error", + error: expect.objectContaining({ code: "UnknownSubcommand" }), + }), + ]); + expect(parseJsonLines(stderr)).toEqual([expect.objectContaining({ _tag: "Help" })]); + })); - test("keeps built-in version and help in text mode for detected coding agents", async () => { - const version = await runSupabase(["--version"], { + test("keeps built-in version and help in text mode for detected coding agents", () => { + const version = runSupabase(["--version"], { entrypoint: "legacy", env: { CODEX_SANDBOX: "1" }, }); - const help = await runSupabase(["--help"], { + const help = runSupabase(["--help"], { entrypoint: "legacy", env: { CODEX_SANDBOX: "1" }, }); - - expect(version.exitCode).toBe(0); - expect(version.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); - expect(() => JSON.parse(version.stdout)).toThrow(); - expect(help.exitCode).toBe(0); - expect(help.stdout).toContain("DESCRIPTION"); - expect(() => JSON.parse(help.stdout)).toThrow(); + return Promise.all([version, help]).then(([versionResult, helpResult]) => { + expect(versionResult.exitCode).toBe(0); + expect(versionResult.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/); + expect(() => JSON.parse(versionResult.stdout)).toThrow(); + expect(helpResult.exitCode).toBe(0); + expect(helpResult.stdout).toContain("DESCRIPTION"); + expect(() => JSON.parse(helpResult.stdout)).toThrow(); + }); }); }); diff --git a/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts index bbf56fe885..bac993e4c5 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.e2e.test.ts @@ -7,36 +7,37 @@ describe("supabase __complete (legacy)", () => { test( "migration li completes to list with a description and the NoFileComp directive", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["__complete", "migration", "li"], { + () => + runSupabase(["__complete", "migration", "li"], { entrypoint: "legacy", - }); - expect(exitCode).toBe(0); - const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list\tList local and remote migrations"); - expect(lines.at(-1)).toBe(":4"); - }, + }).then(({ exitCode, stdout }) => { + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list\tList local and remote migrations"); + expect(lines.at(-1)).toBe(":4"); + }), ); test( "__completeNoDesc strips the description from the same candidate", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["__completeNoDesc", "migration", "li"], { + () => + runSupabase(["__completeNoDesc", "migration", "li"], { entrypoint: "legacy", - }); - expect(exitCode).toBe(0); - const lines = stdout.trim().split("\n"); - expect(lines[0]).toBe("list"); - expect(lines.at(-1)).toBe(":4"); - }, + }).then(({ exitCode, stdout }) => { + expect(exitCode).toBe(0); + const lines = stdout.trim().split("\n"); + expect(lines[0]).toBe("list"); + expect(lines.at(-1)).toBe(":4"); + }), ); - test("root-level flag-name completion offers --debug", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stdout } = await runSupabase(["__complete", "--d"], { + test("root-level flag-name completion offers --debug", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["__complete", "--d"], { entrypoint: "legacy", - }); - expect(exitCode).toBe(0); - expect(stdout).toContain("--debug\toutput debug logs to stderr"); - }); + }).then(({ exitCode, stdout }) => { + expect(exitCode).toBe(0); + expect(stdout).toContain("--debug\toutput debug logs to stderr"); + }), + ); }); diff --git a/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts b/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts index 2d5f22922a..1947cde388 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.integration.test.ts @@ -1,3 +1,4 @@ +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { describe, expect, it } from "vitest"; import { CurrentAnalyticsContext } from "../../shared/telemetry/analytics-context.ts"; @@ -47,7 +48,7 @@ function makeCaptureTelemetry( return (exitCode, durationMs) => Effect.runPromise( legacyCaptureCompleteTelemetryEffect(exitCode, durationMs).pipe( - Effect.provide(analyticsLayer), + Effect.provide(Layer.mergeAll(analyticsLayer, BunServices.layer)), ), ); } @@ -74,43 +75,45 @@ function makeDeps( } describe("legacy __complete telemetry (CLI-1965 review finding)", () => { - it("fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", async () => { + it("fires cli_command_executed with command: __complete and exit_code: 0 for a normal completion request", () => { const analytics = mockAnalyticsWithContext(); const { deps } = makeDeps( ["__complete", "migration", "li"], makeCaptureTelemetry(analytics.layer), ); - expect(await legacyTryComplete(deps)).toBe(true); - - const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); - expect(event).toBeDefined(); - expect(event?.command).toBe("__complete"); - expect(event?.properties[PropExitCode]).toBe(0); + return legacyTryComplete(deps).then((result) => { + expect(result).toBe(true); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.command).toBe("__complete"); + expect(event?.properties[PropExitCode]).toBe(0); + }); }); - it("records exit_code: 1 for an unresolvable completion request (zero completion args)", async () => { + it("records exit_code: 1 for an unresolvable completion request (zero completion args)", () => { const analytics = mockAnalyticsWithContext(); const { deps } = makeDeps(["__complete"], makeCaptureTelemetry(analytics.layer)); - expect(await legacyTryComplete(deps)).toBe(true); - - const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); - expect(event).toBeDefined(); - expect(event?.properties[PropExitCode]).toBe(1); + return legacyTryComplete(deps).then((result) => { + expect(result).toBe(true); + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event).toBeDefined(); + expect(event?.properties[PropExitCode]).toBe(1); + }); }); - it("records command: __complete — never __completeNoDesc — when invoked via the no-descriptions alias", async () => { + it("records command: __complete — never __completeNoDesc — when invoked via the no-descriptions alias", () => { const analytics = mockAnalyticsWithContext(); const { deps } = makeDeps( ["__completeNoDesc", "migration", "li"], makeCaptureTelemetry(analytics.layer), ); - await legacyTryComplete(deps); - - const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); - expect(event?.command).toBe("__complete"); - expect(event?.command).not.toBe("__completeNoDesc"); + return legacyTryComplete(deps).then(() => { + const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); + expect(event?.command).toBe("__complete"); + expect(event?.command).not.toBe("__completeNoDesc"); + }); }); }); diff --git a/apps/cli/src/legacy/cli/legacy-complete.ts b/apps/cli/src/legacy/cli/legacy-complete.ts index 0ae909afae..cad575bb36 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.ts @@ -1,5 +1,6 @@ -import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option } from "effect"; +import { BunCrypto, BunServices } from "@effect/platform-bun"; +import { Clock, Crypto, DateTime, Effect, Layer, Option } from "effect"; +import type * as PlatformError from "effect/PlatformError"; import { GlobalFlag } from "effect/unstable/cli"; import type { Command, Param, Primitive } from "effect/unstable/cli"; import process from "node:process"; @@ -907,12 +908,8 @@ function legacyIsValidGoRfc3339(value: string): boolean { if (offsetHour !== undefined && (Number(offsetHour) > 24 || Number(offsetMinute) > 60)) return false; - const roundTrip = new Date(0); - roundTrip.setUTCFullYear(y, mo - 1, d); - return ( - roundTrip.getUTCFullYear() === y && - roundTrip.getUTCMonth() === mo - 1 && - roundTrip.getUTCDate() === d + return Option.isSome( + DateTime.make({ year: y, month: mo, day: d, hour: 0, minute: 0, second: 0, millisecond: 0 }), ); } @@ -1685,21 +1682,25 @@ export function legacyFormatCompletionResponse( export function legacyCaptureCompleteTelemetryEffect( exitCode: number, durationMs: number, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const analytics = yield* Analytics; - yield* analytics.capture(EventCommandExecuted, { - [PropExitCode]: exitCode, - [PropDurationMs]: durationMs, - [PropOutputFormat]: "text", - }); - }).pipe( - withAnalyticsContext({ - command_run_id: crypto.randomUUID(), - command: "__complete", - flags: undefined, - }), - ); + const crypto = yield* Crypto.Crypto; + const commandRunId = yield* crypto.randomUUIDv4; + yield* analytics + .capture(EventCommandExecuted, { + [PropExitCode]: exitCode, + [PropDurationMs]: durationMs, + [PropOutputFormat]: "text", + }) + .pipe( + withAnalyticsContext({ + command_run_id: commandRunId, + command: "__complete", + flags: undefined, + }), + ); + }); } const LEGACY_COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; @@ -1713,6 +1714,7 @@ const legacyCompleteAnalyticsLayer = legacyAnalyticsLayer.pipe( Layer.provide(standaloneAnalyticsConfigLayer), Layer.provide(BunServices.layer), ); +const legacyCompleteRuntimeLayer = Layer.mergeAll(legacyCompleteAnalyticsLayer, BunCrypto.layer); /** * Production default for `LegacyCompleteDeps.captureTelemetry`: runs @@ -1729,7 +1731,7 @@ const legacyCompleteAnalyticsLayer = legacyAnalyticsLayer.pipe( function legacyCaptureCompleteTelemetry(exitCode: number, durationMs: number): Promise { return Effect.runPromise( legacyCaptureCompleteTelemetryEffect(exitCode, durationMs).pipe( - Effect.provide(legacyCompleteAnalyticsLayer), + Effect.provide(legacyCompleteRuntimeLayer), Effect.timeout(LEGACY_COMPLETE_TELEMETRY_TIMEOUT), Effect.ignore, ), @@ -1747,22 +1749,34 @@ function legacyCaptureCompleteTelemetry(exitCode: number, durationMs: number): P * lets it actually reach PostHog (see `legacyCaptureCompleteTelemetry`'s doc * comment). */ -export async function legacyTryComplete(deps: LegacyCompleteDeps): Promise { - if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; - - const startedAt = Date.now(); - const response = legacyRespondToComplete(deps.root, deps.argv); - if (response === undefined) { - await deps.captureTelemetry(1, Date.now() - startedAt); - deps.exit(1); - return true; - } +export function legacyTryComplete(deps: LegacyCompleteDeps): Promise { + return Effect.runPromise( + Effect.gen(function* () { + if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; + + const startedAt = yield* Clock.currentTimeMillis; + const response = legacyRespondToComplete(deps.root, deps.argv); + if (response === undefined) { + const finishedAt = yield* Clock.currentTimeMillis; + yield* Effect.tryPromise({ + try: () => deps.captureTelemetry(1, finishedAt - startedAt), + catch: () => undefined, + }).pipe(Effect.ignore); + deps.exit(1); + return true; + } - const includeDescriptions = legacyResolveIncludeDescriptions(deps.argv[0], deps.env); - deps.stdoutWrite(legacyFormatCompletionResponse(response, includeDescriptions)); - await deps.captureTelemetry(0, Date.now() - startedAt); - deps.exit(0); - return true; + const includeDescriptions = legacyResolveIncludeDescriptions(deps.argv[0], deps.env); + deps.stdoutWrite(legacyFormatCompletionResponse(response, includeDescriptions)); + const finishedAt = yield* Clock.currentTimeMillis; + yield* Effect.tryPromise({ + try: () => deps.captureTelemetry(0, finishedAt - startedAt), + catch: () => undefined, + }).pipe(Effect.ignore); + deps.exit(0); + return true; + }).pipe(Effect.provide(BunServices.layer)), + ); } export function legacyDefaultCompleteDeps(root: Command.Command.Any): LegacyCompleteDeps { diff --git a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts index 08cb85509a..6978c3f8d2 100644 --- a/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts +++ b/apps/cli/src/legacy/cli/legacy-complete.unit.test.ts @@ -1491,7 +1491,7 @@ describe("legacyTryComplete", () => { // production capture (`legacyCaptureCompleteTelemetryEffect`, // `legacyDefaultCompleteDeps`'s own default) is covered separately in // `legacy-complete.integration.test.ts`. - captureTelemetry: async () => {}, + captureTelemetry: () => Promise.resolve(), ...overrides, }; return { deps, stdoutWrites, exits }; @@ -1499,33 +1499,40 @@ describe("legacyTryComplete", () => { // `legacyTryComplete` returns `Promise` — it awaits // `deps.captureTelemetry` before calling `deps.exit`. - it("returns false and does nothing for non-__complete argv", async () => { + it("returns false and does nothing for non-__complete argv", () => { const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); - expect(await legacyTryComplete(deps)).toBe(false); - expect(stdoutWrites).toEqual([]); - expect(exits).toEqual([]); + return legacyTryComplete(deps).then((result) => { + expect(result).toBe(false); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([]); + }); }); - it("writes the formatted response to stdout and exits 0 for a real completion request", async () => { + it("writes the formatted response to stdout and exits 0 for a real completion request", () => { const { deps, stdoutWrites, exits } = makeDeps(); - expect(await legacyTryComplete(deps)).toBe(true); - expect(stdoutWrites).toHaveLength(1); - expect(stdoutWrites[0]).toContain("list\t"); - expect(stdoutWrites[0]).toMatch(/:4\n$/); - expect(exits).toEqual([0]); + return legacyTryComplete(deps).then((result) => { + expect(result).toBe(true); + expect(stdoutWrites).toHaveLength(1); + expect(stdoutWrites[0]).toContain("list\t"); + expect(stdoutWrites[0]).toMatch(/:4\n$/); + expect(exits).toEqual([0]); + }); }); - it("respects __completeNoDesc by stripping descriptions from the written response", async () => { + it("respects __completeNoDesc by stripping descriptions from the written response", () => { const { deps, stdoutWrites } = makeDeps({ argv: ["__completeNoDesc", "migration", "li"] }); - await legacyTryComplete(deps); - expect(stdoutWrites[0]).toBe("list\n:4\n"); + return legacyTryComplete(deps).then(() => { + expect(stdoutWrites[0]).toBe("list\n:4\n"); + }); }); - it("exits 1 and does not write anything to stdout for zero completion args", async () => { + it("exits 1 and does not write anything to stdout for zero completion args", () => { const { deps, stdoutWrites, exits } = makeDeps({ argv: ["__complete"] }); - expect(await legacyTryComplete(deps)).toBe(true); - expect(stdoutWrites).toEqual([]); - expect(exits).toEqual([1]); + return legacyTryComplete(deps).then((result) => { + expect(result).toBe(true); + expect(stdoutWrites).toEqual([]); + expect(exits).toEqual([1]); + }); }); }); diff --git a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts index baee7e116e..0000fd79ad 100644 --- a/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/list/list.integration.test.ts @@ -1,10 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import { type V1ListAllBackupsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -237,34 +234,36 @@ WalgEnabled = true }); it.live("reads supabase/.temp/project-ref when env and flag are unset", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-fileref-")); const fileRef = "filerefabcdefghijklm"; - mkdirSync(join(localTempRoot, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(localTempRoot, "supabase", ".temp", "project-ref"), fileRef); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: PITR_RESPONSE } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tempRoot.current, "supabase", ".temp"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(tempRoot.current, "supabase", ".temp", "project-ref"), + fileRef, + ); yield* legacyBackupsList({ projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer))); }); it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-list-int-no-ref-")); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: PITR_RESPONSE } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); @@ -275,11 +274,9 @@ WalgEnabled = true ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } - }).pipe( - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }); }); it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { @@ -288,7 +285,7 @@ WalgEnabled = true const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.some("BADREF") })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -299,7 +296,7 @@ WalgEnabled = true const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyBackupListUnexpectedStatusError"); expect(errorJson).toContain("unexpected list backup status 503"); } @@ -312,7 +309,7 @@ WalgEnabled = true const exit = yield* Effect.exit(legacyBackupsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyBackupListNetworkError"); expect(errorJson).toContain("failed to list physical backups"); } diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts index 09026a072e..3767e5d439 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.handler.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Formatter } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -53,7 +53,8 @@ export const legacyBackupsRestore = Effect.fn("legacy.backups.restore")(function // structured payload (Go has no JSON for restore — adding one is non-breaking). if (goFmt === "json") { yield* output.raw( - JSON.stringify({ message: "Started PITR restore", project_ref: ref }, null, 2) + "\n", + Formatter.formatJson({ message: "Started PITR restore", project_ref: ref }, { space: 2 }) + + "\n", ); return; } diff --git a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts index 390085d2d3..1b2a35e7c4 100644 --- a/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/legacy/commands/backups/restore/restore.integration.test.ts @@ -1,9 +1,5 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; @@ -151,7 +147,7 @@ describe("legacy backups restore integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyBackupRestoreUnexpectedStatusError"); expect(errorJson).toContain("unexpected restore backup status 503"); } @@ -166,7 +162,7 @@ describe("legacy backups restore integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyBackupRestoreNetworkError"); expect(errorJson).toContain("failed to restore backup"); } @@ -174,11 +170,10 @@ describe("legacy backups restore integration", () => { }); it.live("fails with LegacyProjectNotLinkedError non-interactively when no ref source", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-noref-")); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({}); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); @@ -191,15 +186,12 @@ describe("legacy backups restore integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } - }).pipe( - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }); }); it.live("prompts via TTY when no ref source matches and stdin is a TTY", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-backups-restore-int-prompt-")); const out = mockOutput({ format: "text", promptSelectResponses: [LEGACY_VALID_REF], @@ -214,7 +206,7 @@ describe("legacy backups restore integration", () => { HttpClientResponse.fromWeb( request, new Response( - JSON.stringify([ + Formatter.formatJson([ { id: LEGACY_VALID_REF, ref: LEGACY_VALID_REF, @@ -241,7 +233,7 @@ describe("legacy backups restore integration", () => { }, }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ @@ -257,9 +249,7 @@ describe("legacy backups restore integration", () => { ); expect(out.promptSelectCalls).toHaveLength(1); expect(out.stderrText).toContain(`Started PITR restore: ${LEGACY_VALID_REF}\n`); - }).pipe( - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }); }); it.live("accepts --timestamp short alias -t in the same way (no separate parse path)", () => { diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 0a74c5bbc7..e47a4feed5 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path, Schedule } from "effect"; +import { Cause, Effect, FileSystem, Option, Path, Schedule } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; @@ -22,8 +22,10 @@ import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { Tty } from "../../../shared/runtime/tty.service.ts"; import { legacyAqua, legacyBold } from "../../shared/legacy-colors.ts"; import { legacyEnsureLogin } from "../../shared/legacy-ensure-login.ts"; +import { LegacyViperEnv } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyGetProjectApiKeys } from "../../shared/legacy-get-api-keys.ts"; import { sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; +import { legacyErrorMessage } from "../../shared/legacy-error-message.ts"; import type { LegacyConnectSuggestionContext } from "../../shared/legacy-connect-errors.ts"; import { legacyResolveLinkedConn } from "../../shared/legacy-db-config.layer.ts"; import { @@ -79,6 +81,7 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( const telemetryState = yield* LegacyTelemetryState; const workdirFlag = yield* LegacyWorkdirFlag; const dnsResolver = yield* LegacyDnsResolverFlag; + const viperEnv = yield* LegacyViperEnv; // `--yes` OR `SUPABASE_YES`. const yesFlag = yield* legacyResolveYes; @@ -100,7 +103,7 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( // Reads the prefixed `SUPABASE_WORKDIR` only (never plain `WORKDIR`). const workdirRaw = Option.isSome(workdirFlag) ? workdirFlag.value - : process.env["SUPABASE_WORKDIR"]; + : Option.getOrUndefined(yield* viperEnv.get("SUPABASE_WORKDIR")); const workdirInput = workdirRaw ?? (yield* output.promptText( @@ -136,14 +139,14 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( // C. mkdir + overwrite prompt. yield* fs.makeDirectory(workdir, { recursive: true }); - const entries = yield* fs - .readDirectory(workdir) - .pipe( - Effect.mapError( - (cause) => - new LegacyBootstrapWorkdirReadError({ message: `failed to read workdir: ${cause}` }), - ), - ); + const entries = yield* fs.readDirectory(workdir).pipe( + Effect.mapError( + (cause) => + new LegacyBootstrapWorkdirReadError({ + message: `failed to read workdir: ${legacyErrorMessage(cause)}`, + }), + ), + ); if (entries.length > 0) { // Established prompt behavior: `--yes`/`SUPABASE_YES` auto-confirms with // the ` [Y/n] y` stderr echo instead of silently skipping the @@ -194,7 +197,7 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( // fallback is `SUPABASE_DB_PASSWORD` (consumed by `flags.PromptPassword`). const seededPassword = Option.isSome(flags.password) ? flags.password.value - : (process.env["SUPABASE_DB_PASSWORD"] ?? ""); + : Option.getOrElse(yield* viperEnv.get("SUPABASE_DB_PASSWORD"), () => ""); const created = yield* legacyProjectCreateCore({ name: path.basename(workdir), orgId: "", @@ -230,8 +233,11 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( // here and stays open for the rest of the handler — see the `Effect.scoped` // on this function's own outer pipe below. const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); - yield* legacyApplyProjectEnv(projectEnv); - const pushYes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const effectiveProjectEnv = { + ...projectEnv, + ...(yield* legacyApplyProjectEnv(projectEnv)), + }; + const pushYes = yield* legacyResolveYesWithProjectEnv(effectiveProjectEnv); const toml = yield* legacyCheckDbToml(fs, path, workdir, projectRef); if (toml.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); @@ -280,7 +286,8 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( const content = yield* fs.readFileString(examplePath); example = yield* Effect.try({ try: () => parseDotEnv(content), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => + new Cause.UnknownError(cause, cause instanceof Error ? cause.message : String(cause)), }); } const env = buildDotEnv(keys, dbConfig, supabaseUrl, example); @@ -378,14 +385,14 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( includeVault: true, dnsResolver, projectId: cliConfig.projectId, - toml, + toml: { ...toml, projectEnv: { ...toml.projectEnv, ...effectiveProjectEnv } }, yes: pushYes, emitStructuredResult: false, }).pipe(pushNotify, Effect.retry(retry)); // M. Start suggestion. if (isText) { - const suggestion = suggestAppStart(runtimeInfo.cwd, workdir, starter.start, legacyAqua); + const suggestion = suggestAppStart(path, runtimeInfo.cwd, workdir, starter.start, legacyAqua); yield* emitSuccessTrailer(`${suggestion}\n`); } else { yield* output.success("", { @@ -452,7 +459,13 @@ const mapHealthError = (cause: unknown): Effect.Effect<never, LegacyBootstrapHea } return Effect.fail( isDecodeFailureCause(cause) - ? new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, decode: true }) - : new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }), + ? new LegacyBootstrapHealthError({ + message: `Error status 0: ${legacyErrorMessage(cause)}`, + decode: true, + }) + : new LegacyBootstrapHealthError({ + message: `Error status 0: ${legacyErrorMessage(cause)}`, + transport: true, + }), ); }; diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index 6a67e8d0aa..69337009aa 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -1,9 +1,16 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Schedule } from "effect"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Path, + Schedule, +} from "effect"; import { mockAnalytics, @@ -34,6 +41,7 @@ import { LegacyYesFlag, LegacyOutputFlag, } from "../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { LegacyDbConnectError } from "../../shared/legacy-db-connection.errors.ts"; import { @@ -70,6 +78,17 @@ const API_KEYS = [ const HEALTHY = [{ name: "db", healthy: true, status: "ACTIVE_HEALTHY" }]; const tempRoot = useLegacyTempWorkdir("supabase-bootstrap-int-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + +const readText = (file: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).readFileString(file); + }); + +const exists = (file: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).exists(file); + }); const NEXTJS_TEMPLATE: LegacyStarterTemplate = { name: "nextjs", @@ -100,9 +119,14 @@ interface SetupOpts { readonly promptTextResponses?: ReadonlyArray<string>; readonly promptConfirmResponses?: ReadonlyArray<boolean>; readonly promptPasswordResponses?: ReadonlyArray<string>; + readonly env?: Readonly<Record<string, string>>; } function setup(opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: opts.format ?? "text", promptTextResponses: opts.promptTextResponses, @@ -226,6 +250,8 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, api.layer, api.factoryLayer, @@ -234,7 +260,7 @@ function setup(opts: SetupOpts = {}) { mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), // cwd differs from the (absolute) workdir so the "Using workdir" line prints, // matching the established `cwd != CurrentDirAbs` guard. - mockRuntimeInfo({ cwd: dirname(tempRoot.current) }), + mockRuntimeInfo({ cwd: path.dirname(tempRoot.current) }), telemetry.layer, linkedCache.layer, analytics.layer, @@ -289,13 +315,13 @@ describe("legacy bootstrap integration", () => { return Effect.gen(function* () { yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); // Blank init scaffolded config.toml. - expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(true); + expect(yield* exists(path.join(s.workdir, "supabase", "config.toml"))).toBe(true); // Project ref written for the delegated db push. - expect(readFileSync(join(s.workdir, "supabase", ".temp", "project-ref"), "utf8")).toBe( + expect(yield* readText(path.join(s.workdir, "supabase", ".temp", "project-ref"))).toBe( LEGACY_VALID_REF, ); // .env populated with derived keys. - const env = readFileSync(join(s.workdir, ".env"), "utf8"); + const env = yield* readText(path.join(s.workdir, ".env")); expect(env).toContain('SUPABASE_ANON_KEY="anon-key"'); expect(env).toContain("SUPABASE_URL="); expect(env).toContain("POSTGRES_URL="); @@ -312,7 +338,7 @@ describe("legacy bootstrap integration", () => { yield* legacyBootstrap(flags({ template: Option.some("NextJS") }), FAST_BACKOFF); expect(s.downloads).toHaveLength(1); expect(s.downloads[0]).toEqual({ url: NEXTJS_TEMPLATE.url, targetDir: s.workdir }); - expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(false); + expect(yield* exists(path.join(s.workdir, "supabase", "config.toml"))).toBe(false); expect(s.out.stdoutText).toContain(`Downloading: ${NEXTJS_TEMPLATE.url}`); }).pipe(Effect.provide(s.layer)); }); @@ -325,7 +351,7 @@ describe("legacy bootstrap integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyBootstrapInvalidTemplateError"); expect(json).toContain("Invalid template: nope"); } @@ -349,45 +375,42 @@ describe("legacy bootstrap integration", () => { workdir: Option.none(), promptTextResponses: [tempRoot.current], }); - const prevWorkdir = process.env["SUPABASE_WORKDIR"]; - delete process.env["SUPABASE_WORKDIR"]; return Effect.gen(function* () { yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); - expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(true); - }).pipe( - Effect.provide(s.layer), - Effect.ensuring( - Effect.sync(() => { - if (prevWorkdir !== undefined) process.env["SUPABASE_WORKDIR"] = prevWorkdir; - }), - ), - ); + expect(yield* exists(path.join(s.workdir, "supabase", "config.toml"))).toBe(true); + }).pipe(Effect.provide(s.layer)); }); it.live("aborts when the user declines to overwrite a non-empty workdir", () => { const s = setup({ promptConfirmResponses: [false] }); - writeFileSync(join(tempRoot.current, "existing.txt"), "keep me"); return Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).writeFileString( + path.join(tempRoot.current, "existing.txt"), + "keep me", + ); const exit = yield* Effect.exit( legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyBootstrapOverwriteDeclinedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyBootstrapOverwriteDeclinedError"); } }).pipe(Effect.provide(s.layer)); }); it.live("proceeds past a non-empty workdir with --yes", () => { const s = setup({ yes: true }); - writeFileSync(join(tempRoot.current, "existing.txt"), "keep me"); return Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).writeFileString( + path.join(tempRoot.current, "existing.txt"), + "keep me", + ); yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); // Established behavior: the auto-accepted overwrite question echoes to // stderr under the global YES flag. expect(s.out.stderrText).toContain("Do you want to overwrite existing files in "); expect(s.out.stderrText).toContain(" directory? [Y/n] y\n"); - expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(true); + expect(yield* exists(path.join(s.workdir, "supabase", "config.toml"))).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -454,7 +477,7 @@ describe("legacy bootstrap integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("Service not healthy: db (UNHEALTHY)"); + expect(Formatter.formatJson(exit.cause)).toContain("Service not healthy: db (UNHEALTHY)"); } }).pipe(Effect.provide(s.layer)); }); @@ -467,21 +490,21 @@ describe("legacy bootstrap integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("Error status 503"); + expect(Formatter.formatJson(exit.cause)).toContain("Error status 503"); } }).pipe(Effect.provide(s.layer)); }); it.live("merges .env.example derived keys", () => { const s = setup(); - mkdirSync(tempRoot.current, { recursive: true }); - writeFileSync( - join(tempRoot.current, ".env.example"), - "POSTGRES_USER=example\nNEXT_PUBLIC_SUPABASE_ANON_KEY=example\n", - ); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + path.join(tempRoot.current, ".env.example"), + "POSTGRES_USER=example\nNEXT_PUBLIC_SUPABASE_ANON_KEY=example\n", + ); yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); - const env = readFileSync(join(s.workdir, ".env"), "utf8"); + const env = yield* readText(path.join(s.workdir, ".env")); expect(env).toContain('POSTGRES_USER="postgres"'); expect(env).toContain('NEXT_PUBLIC_SUPABASE_ANON_KEY="anon-key"'); }).pipe(Effect.provide(s.layer)); @@ -489,9 +512,11 @@ describe("legacy bootstrap integration", () => { it.live("continues (non-fatal) when the .env.example is malformed", () => { const s = setup(); - mkdirSync(tempRoot.current, { recursive: true }); - writeFileSync(join(tempRoot.current, ".env.example"), "!="); return Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).writeFileString( + path.join(tempRoot.current, ".env.example"), + "!=", + ); yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); expect(s.out.stderrText).toContain("Failed to create .env file:"); // Bootstrap still completes through the native db push step. @@ -564,44 +589,24 @@ describe("legacy bootstrap integration", () => { // step always uses the create-resolved password; there is no separate // flag/env channel to preserve once the call is in-process, CLI-1953). const s = setup({ promptPasswordResponses: ["prompted-pw"] }); - const prev = process.env["SUPABASE_DB_PASSWORD"]; - delete process.env["SUPABASE_DB_PASSWORD"]; return Effect.gen(function* () { yield* legacyBootstrap( flags({ template: Option.some("scratch"), password: Option.some("") }), FAST_BACKOFF, ); expect(s.pushConnectCalls[0]?.password).toBe("prompted-pw"); - }).pipe( - Effect.provide(s.layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = prev; - }), - ), - ); + }).pipe(Effect.provide(s.layer)); }); it.live("pushes with a SUPABASE_DB_PASSWORD env var-sourced password", () => { - const s = setup(); - const prev = process.env["SUPABASE_DB_PASSWORD"]; - process.env["SUPABASE_DB_PASSWORD"] = "env-pw"; + const s = setup({ env: { SUPABASE_DB_PASSWORD: "env-pw" } }); return Effect.gen(function* () { yield* legacyBootstrap( flags({ template: Option.some("scratch"), password: Option.none() }), FAST_BACKOFF, ); expect(s.pushConnectCalls[0]?.password).toBe("env-pw"); - }).pipe( - Effect.provide(s.layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = prev; - }), - ), - ); + }).pipe(Effect.provide(s.layer)); }); it.live("flushes telemetry and caches the linked project via ensuring", () => { @@ -631,9 +636,11 @@ describe("legacy bootstrap integration", () => { it.live("reports env_file: null in the json result when the .env write fails", () => { const s = setup({ format: "json" }); - mkdirSync(tempRoot.current, { recursive: true }); - writeFileSync(join(tempRoot.current, ".env.example"), "!="); return Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).writeFileString( + path.join(tempRoot.current, ".env.example"), + "!=", + ); yield* legacyBootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ env_file: null }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts index 8525db8c87..5264e5dfdb 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -50,6 +50,7 @@ import { LegacyPlatformApi } from "../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../config/legacy-project-ref.service.ts"; import { LegacyIdentityStitch } from "../../shared/legacy-identity-stitch.ts"; import { LegacyTemplateService } from "./bootstrap.templates.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyBootstrapRuntimeLayer } from "./bootstrap.layers.ts"; @@ -124,6 +125,7 @@ function ambientStubs() { mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, heavyServiceStubs, + makeLegacyViperEnvLayer(), ); } @@ -134,7 +136,7 @@ describe("legacyBootstrapRuntimeLayer — LegacyIdentityStitch exposure", () => return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(legacyBootstrapRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyBootstrapRuntimeLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.ts index 52e96a3296..2f86e1d523 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.ts @@ -1,4 +1,4 @@ -import { relative } from "node:path"; +import type { Path } from "effect"; /** * Builds the "To start your app:" hint printed at the end of bootstrap. Computes @@ -11,12 +11,13 @@ import { relative } from "node:path"; * the raw text, matching a non-TTY (uncoloured) profile. */ export function suggestAppStart( + path: Pick<Path.Path, "relative">, currentDirAbs: string, workdir: string, command: string, colorize: (line: string) => string = (line) => line, ): string { - const rel = relative(currentDirAbs, workdir); + const rel = path.relative(currentDirAbs, workdir); const lines: Array<string> = []; if (rel.length > 0 && rel !== ".") { lines.push(`cd ${rel}`); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.unit.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.unit.test.ts index 503bd2b00a..9267930cf2 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.unit.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.suggest.unit.test.ts @@ -1,35 +1,41 @@ +import { BunPath } from "@effect/platform-bun"; import { describe, expect, it } from "vitest"; +import { Effect, Path } from "effect"; import { suggestAppStart } from "./bootstrap.suggest.ts"; +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + // Colour is identity here so the assertions match the established non-TTY // (uncoloured) output byte-for-byte. describe("suggestAppStart", () => { it("suggests the start command when the workdir is the current directory", () => { - expect(suggestAppStart("/home/me/app", "/home/me/app", "npm ci && npm run dev")).toBe( + expect(suggestAppStart(path, "/home/me/app", "/home/me/app", "npm ci && npm run dev")).toBe( "To start your app:\n npm ci && npm run dev", ); }); it("prefixes a cd line when the workdir is nested", () => { - expect(suggestAppStart("/home/me", "/home/me/app", "npm ci && npm run dev")).toBe( + expect(suggestAppStart(path, "/home/me", "/home/me/app", "npm ci && npm run dev")).toBe( "To start your app:\n cd app\n npm ci && npm run dev", ); }); it("omits the cd line for a '.' relative path", () => { - expect(suggestAppStart("/home/me/app", "/home/me/app", "supabase start")).toBe( + expect(suggestAppStart(path, "/home/me/app", "/home/me/app", "supabase start")).toBe( "To start your app:\n supabase start", ); }); it("omits the command line when the start command is empty", () => { - expect(suggestAppStart("/home/me", "/home/me/app", "")).toBe("To start your app:\n cd app"); + expect(suggestAppStart(path, "/home/me", "/home/me/app", "")).toBe( + "To start your app:\n cd app", + ); }); it("applies the colorize callback to each command line", () => { const aqua = (line: string) => `<${line}>`; - expect(suggestAppStart("/home/me", "/home/me/app", "npm run dev", aqua)).toBe( + expect(suggestAppStart(path, "/home/me", "/home/me/app", "npm run dev", aqua)).toBe( "To start your app:\n <cd app>\n <npm run dev>", ); }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.templates.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.templates.ts index a490dbc763..dfbd72ad17 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.templates.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.templates.ts @@ -1,9 +1,10 @@ -import { Context, Effect, FileSystem, Layer, Path } from "effect"; +import { Config, Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { Output } from "../../../shared/output/output.service.ts"; import { sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; +import { legacyErrorMessage } from "../../shared/legacy-error-message.ts"; import { LegacyBootstrapTemplateDownloadError, LegacyBootstrapTemplateListError, @@ -55,6 +56,10 @@ interface GithubContentEntry { readonly download_url?: string | null; } +const GithubSamplesSchema = Schema.Struct({ + samples: Schema.optional(Schema.Array(Schema.Unknown)), +}); + function isStarterTemplate(value: unknown): value is LegacyStarterTemplate { return ( typeof value === "object" && @@ -72,7 +77,7 @@ const mapDownloadError = ( cause instanceof LegacyBootstrapTemplateDownloadError ? cause : new LegacyBootstrapTemplateDownloadError({ - message: `failed to download template: ${cause}`, + message: `failed to download template: ${legacyErrorMessage(cause)}`, }), ); @@ -86,7 +91,7 @@ export const legacyTemplateServiceLayer = Layer.effect( // Go reads `GITHUB_TOKEN` directly (`utils.GetGitHubClient`) to raise the // anonymous GitHub API rate limit. When unset, requests are anonymous. - const githubToken = process.env["GITHUB_TOKEN"]; + const githubToken = yield* Config.string("GITHUB_TOKEN").pipe(Config.withDefault("")); const contentsRequest = (owner: string, repo: string, contentPath: string, ref: string) => { const encodedPath = contentPath @@ -114,7 +119,7 @@ export const legacyTemplateServiceLayer = Layer.effect( Effect.mapError( (cause) => new LegacyBootstrapTemplateListError({ - message: `failed to list samples: ${cause}`, + message: `failed to list samples: ${legacyErrorMessage(cause)}`, }), ), ); @@ -129,20 +134,25 @@ export const legacyTemplateServiceLayer = Layer.effect( const payload = yield* response.json.pipe( Effect.mapError( (cause) => - new LegacyBootstrapTemplateListError({ message: `failed to decode samples: ${cause}` }), + new LegacyBootstrapTemplateListError({ + message: `failed to decode samples: ${legacyErrorMessage(cause)}`, + }), ), ); const decoded = Buffer.from( ((payload as GithubContentEntry).content ?? "").replaceAll("\n", ""), "base64", ).toString("utf8"); - const parsed = yield* Effect.try({ - try: () => JSON.parse(decoded) as { samples?: ReadonlyArray<unknown> }, - catch: (cause) => - new LegacyBootstrapTemplateListError({ - message: `failed to unmarshal samples: ${cause}`, - }), - }); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(GithubSamplesSchema))( + decoded, + ).pipe( + Effect.mapError( + (cause) => + new LegacyBootstrapTemplateListError({ + message: `failed to unmarshal samples: ${legacyErrorMessage(cause)}`, + }), + ), + ); return (parsed.samples ?? []).filter(isStarterTemplate); }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index b85362575f..e12dd8c2fa 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -1,10 +1,6 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Schedule } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Schedule } from "effect"; import { mockAnalytics, @@ -24,6 +20,7 @@ import { mockLegacyLoginCrypto, mockLegacyPlatformApi, mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyDebugFlag, @@ -49,6 +46,7 @@ import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-pro import { LegacyTemplateService } from "./bootstrap.templates.ts"; import { legacyBootstrap } from "./bootstrap.handler.ts"; import type { LegacyBootstrapFlags } from "./bootstrap.command.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; const FAST_BACKOFF = Schedule.exponential("1 milli"); @@ -64,6 +62,8 @@ const PROJECT = { const ORGS = [{ id: "org-1", slug: "acme", name: "Acme Inc" }]; const API_KEYS = [{ name: "anon", api_key: "anon-key" }]; const HEALTHY = [{ name: "db", healthy: true, status: "ACTIVE_HEALTHY" }]; +const tempRoot = useLegacyTempWorkdir("bootstrap-cache-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); // Drives the handler through the *prompt* workdir path (no `--workdir` flag and no // `SUPABASE_WORKDIR` env) with the real config + linked-project-cache layers. This is @@ -75,29 +75,24 @@ describe("legacy bootstrap linked-project cache location", () => { it.live( "writes linked-project.json into the prompted bootstrap workdir, not cliConfig.workdir", () => { - const parent = mkdtempSync(join(tmpdir(), "bootstrap-cache-")); + const parent = path.join(tempRoot.current, "parent"); const subdir = "myproj"; - const bootstrapWorkdir = join(parent, subdir); + const bootstrapWorkdir = path.join(parent, subdir); // Pre-seed a migration file at the bootstrap workdir (before it even exists) so // the push step's migrations lookup is empirically provable: `legacyDbPushCore` // must find it via the `workdir` local variable — the prompted bootstrap // workdir — never `cliConfig.workdir` (the cwd-walk result from `parent`, which // has no `supabase/migrations` of its own and would wrongly report "up to date"). - const migrationsDir = join(bootstrapWorkdir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_test.sql"), "create table t ();"); + const migrationsDir = path.join(bootstrapWorkdir, "supabase", "migrations"); // Also pre-seed `supabase/roles.sql` so the push step's `includeRoles: true` // (bootstrap always passes it) is actually pinned under test — without a // roles.sql file present, the // custom-roles branch is a no-op and `includeRoles`'s value is unasserted. - writeFileSync(join(bootstrapWorkdir, "supabase", "roles.sql"), "create role app;"); - - // Token via env => ensure-login is a no-op and the cache has a bearer token. - const prevToken = process.env["SUPABASE_ACCESS_TOKEN"]; - const prevWorkdir = process.env["SUPABASE_WORKDIR"]; - process.env["SUPABASE_ACCESS_TOKEN"] = "sbp_" + "a".repeat(40); - delete process.env["SUPABASE_WORKDIR"]; + const configProvider = ConfigProvider.fromEnv({ + env: { SUPABASE_ACCESS_TOKEN: "sbp_" + "a".repeat(40) }, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: "text", promptTextResponses: [subdir] }); @@ -206,6 +201,7 @@ describe("legacy bootstrap linked-project cache location", () => { Layer.provide(debugLoggerLayer), Layer.provide(runtime), Layer.provide(BunServices.layer), + Layer.provide(ConfigProvider.layer(configProvider)), ); const cacheLayer = legacyLinkedProjectCacheLayer.pipe( Layer.provide(configLayer), @@ -229,6 +225,7 @@ describe("legacy bootstrap linked-project cache location", () => { const layer = Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(configProvider), out.layer, api.layer, api.factoryLayer, @@ -248,6 +245,7 @@ describe("legacy bootstrap linked-project cache location", () => { mockLegacyLoginCrypto().layer, mockBrowser(), mockStdin(true), + makeLegacyViperEnvLayer(configProvider), flagsLayer, debugLoggerLayer, successTrailerLayer, @@ -259,20 +257,35 @@ describe("legacy bootstrap linked-project cache location", () => { }; return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_test.sql"), + "create table t ();", + ); + yield* fs.writeFileString( + path.join(bootstrapWorkdir, "supabase", "roles.sql"), + "create role app;", + ); const successTrailer = yield* SuccessTrailer; yield* legacyBootstrap(flags, FAST_BACKOFF); expect(yield* successTrailer.workingDirectory).toBe(bootstrapWorkdir); - const projectRef = join(bootstrapWorkdir, "supabase", ".temp", "project-ref"); - const cacheInWorkdir = join(bootstrapWorkdir, "supabase", ".temp", "linked-project.json"); - const cacheInParent = join(parent, "supabase", ".temp", "linked-project.json"); + const projectRef = path.join(bootstrapWorkdir, "supabase", ".temp", "project-ref"); + const cacheInWorkdir = path.join( + bootstrapWorkdir, + "supabase", + ".temp", + "linked-project.json", + ); + const cacheInParent = path.join(parent, "supabase", ".temp", "linked-project.json"); // project-ref already goes to the right place... - expect(existsSync(projectRef)).toBe(true); + expect(yield* fs.exists(projectRef)).toBe(true); // ...so linked-project.json must land beside it (Go writes both into workdir). - expect(existsSync(cacheInWorkdir)).toBe(true); - expect(existsSync(cacheInParent)).toBe(false); + expect(yield* fs.exists(cacheInWorkdir)).toBe(true); + expect(yield* fs.exists(cacheInParent)).toBe(false); // Native push (CLI-1953) correctness: `legacyDbPushCore` connects to the // just-created project (the `projectRef` bootstrap already holds in @@ -304,17 +317,7 @@ describe("legacy bootstrap linked-project cache location", () => { // glob matches nothing, so the push step reports seeds up to date — a // line that only prints at all when `includeSeed` is true. expect(out.stderrText).toContain("Seed files are up to date."); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (prevToken !== undefined) process.env["SUPABASE_ACCESS_TOKEN"] = prevToken; - else delete process.env["SUPABASE_ACCESS_TOKEN"]; - if (prevWorkdir !== undefined) process.env["SUPABASE_WORKDIR"] = prevWorkdir; - rmSync(parent, { recursive: true, force: true }); - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 76a1e1f397..a980bda7be 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -121,16 +121,14 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function Effect.gen(function* () { const mapped = yield* Effect.flip(mapCreateErrorRaw(cause)); if (mapped._tag === "LegacyBranchesCreateUnexpectedStatusError") { - return yield* Effect.fail( - new LegacyBranchesCreateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - }), - ); + return yield* new LegacyBranchesCreateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }), ), ), diff --git a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts index d24091279d..08005b2dca 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.integration.test.ts @@ -1,12 +1,13 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Layer, Option, Schema } from "effect"; import { Command } from "effect/unstable/cli"; import { mockAnalytics, mockOutput, mockStdin, + mockTelemetryRuntime, mockTty, } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -69,6 +70,7 @@ function entitlementResponse(opts: { readonly featureKey: string; readonly hasAc } const tempRoot = useLegacyTempWorkdir("supabase-branches-create-int-"); +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; @@ -84,6 +86,7 @@ interface SetupOpts { /** Piped stdin lines consumed by the non-TTY confirm read. */ readonly stdinInput?: string; readonly promptConfirmResponses?: ReadonlyArray<boolean>; + readonly env?: Record<string, string>; } function buildApiLayer(opts: SetupOpts) { @@ -129,8 +132,12 @@ function setup(opts: SetupOpts = {}) { tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + env: opts.env, }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), ); return { layer, out, api, analytics }; } @@ -150,8 +157,12 @@ function setupTracked(opts: SetupOpts = {}) { analytics, telemetry: telemetry.layer, linkedProjectCache: cache.layer, + env: opts.env, }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), ); return { layer, out, api, telemetry, cache, analytics }; } @@ -221,14 +232,12 @@ describe("legacy branches create integration", () => { }); it.live("reports a missing name before contacting the API outside a git repository", () => { - const previousHead = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; const { layer, api } = setup(); return Effect.gen(function* () { const exit = yield* legacyBranchesCreate(baseFlags).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyBranchesBranchNameEmptyError"); + expect(stringifyJson(exit.cause)).toContain("LegacyBranchesBranchNameEmptyError"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ error_kind: "user_actionable", error_category: "invalid_input", @@ -236,15 +245,7 @@ describe("legacy branches create integration", () => { }); } expect(api.requests).toHaveLength(0); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousHead === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = previousHead; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); // --------------------------------------------------------------------------- @@ -253,97 +254,82 @@ describe("legacy branches create integration", () => { // `detectGitBranch` deterministically (its highest-priority source). // --------------------------------------------------------------------------- - const withGitBranch = <A, E, R>(effect: Effect.Effect<A, E, R>, branch = "feat-y") => { - const prevHead = process.env["GITHUB_HEAD_REF"]; - process.env["GITHUB_HEAD_REF"] = branch; - return effect.pipe( - Effect.ensuring( - Effect.sync(() => { - if (prevHead === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = prevHead; - }), - ), - ); - }; - it.live("--yes auto-confirms the git-branch name with the [Y/n] y echo", () => { - const { layer, out, api } = setup({ yes: true, stdinIsTty: true }); - return withGitBranch( - Effect.gen(function* () { - yield* legacyBranchesCreate(baseFlags); - // Established behavior: the `--yes` branch echoes `<title> [Y/n] y` - // to stderr instead of blocking the TTY prompt. - expect(out.stderrText).toContain("Do you want to create a branch named "); - expect(out.stderrText).toContain("? [Y/n] y\n"); - expect(api.requests[0]?.body).toMatchObject({ - branch_name: "feat-y", - git_branch: "feat-y", - }); - }).pipe(Effect.provide(layer)), - ); + const { layer, out, api } = setup({ + yes: true, + stdinIsTty: true, + env: { GITHUB_HEAD_REF: "feat-y" }, + }); + return Effect.gen(function* () { + yield* legacyBranchesCreate(baseFlags); + // Established behavior: the `--yes` branch echoes `<title> [Y/n] y` + // to stderr instead of blocking the TTY prompt. + expect(out.stderrText).toContain("Do you want to create a branch named "); + expect(out.stderrText).toContain("? [Y/n] y\n"); + expect(api.requests[0]?.body).toMatchObject({ + branch_name: "feat-y", + git_branch: "feat-y", + }); + }).pipe(Effect.provide(layer)); }); it.live("SUPABASE_YES=1 auto-confirms the git-branch name like --yes", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const { layer, out, api } = setup({ stdinIsTty: true }); - return withGitBranch( - Effect.gen(function* () { - yield* legacyBranchesCreate(baseFlags); - expect(out.stderrText).toContain("? [Y/n] y\n"); - expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ), - ); + const { layer, out, api } = setup({ + stdinIsTty: true, + env: { GITHUB_HEAD_REF: "feat-y", SUPABASE_YES: "1" }, + }); + return Effect.gen(function* () { + yield* legacyBranchesCreate(baseFlags); + expect(out.stderrText).toContain("? [Y/n] y\n"); + expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); + }).pipe(Effect.provide(layer)); }); it.live("non-TTY with piped `n` declines the git-branch name like Go", () => { - const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "n\n" }); - return withGitBranch( - Effect.gen(function* () { - const exit = yield* Effect.exit(legacyBranchesCreate(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyBranchesCreateCancelledError"); - } - // The piped answer is echoed to stderr, matching the non-TTY prompt. - expect(out.stderrText).toContain("? [Y/n] n\n"); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + const { layer, out, api } = setup({ + stdinIsTty: false, + stdinInput: "n\n", + env: { GITHUB_HEAD_REF: "feat-y" }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBranchesCreate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(stringifyJson(exit.cause)).toContain("LegacyBranchesCreateCancelledError"); + } + // The piped answer is echoed to stderr, matching the non-TTY prompt. + expect(out.stderrText).toContain("? [Y/n] n\n"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }); it.live("non-TTY with empty stdin takes the Yes default and creates the branch", () => { - const { layer, out, api } = setup({ stdinIsTty: false }); - return withGitBranch( - Effect.gen(function* () { - yield* legacyBranchesCreate(baseFlags); - // Label printed, empty scan echoed, true default wins (`console.go:64-102`). - expect(out.stderrText).toContain("? [Y/n] \n"); - expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); - }).pipe(Effect.provide(layer)), - ); + const { layer, out, api } = setup({ + stdinIsTty: false, + env: { GITHUB_HEAD_REF: "feat-y" }, + }); + return Effect.gen(function* () { + yield* legacyBranchesCreate(baseFlags); + // Label printed, empty scan echoed, true default wins (`console.go:64-102`). + expect(out.stderrText).toContain("? [Y/n] \n"); + expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); + }).pipe(Effect.provide(layer)); }); it.live("TTY decline of the git-branch name cancels without creating", () => { - const { layer, api } = setup({ stdinIsTty: true, promptConfirmResponses: [false] }); - return withGitBranch( - Effect.gen(function* () { - const exit = yield* Effect.exit(legacyBranchesCreate(baseFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyBranchesCreateCancelledError"); - } - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + const { layer, api } = setup({ + stdinIsTty: true, + promptConfirmResponses: [false], + env: { GITHUB_HEAD_REF: "feat-y" }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyBranchesCreate(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(stringifyJson(exit.cause)).toContain("LegacyBranchesCreateCancelledError"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }); it.live("emits a success event for --output-format=json", () => { @@ -373,7 +359,7 @@ describe("legacy branches create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesCreateNetworkError"); expect(json).toContain("failed to create preview branch"); } @@ -388,7 +374,7 @@ describe("legacy branches create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesCreateUnexpectedStatusError"); expect(json).toContain("unexpected create branch status 500"); } @@ -468,6 +454,7 @@ describe("legacy branches create integration", () => { // previously listed "nano" as a valid choice, silently succeeding where // it should error. it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const { layer } = setup(); const root = Command.make("supabase").pipe( Command.withSubcommands([legacyBranchesCreateCommand]), Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), @@ -481,7 +468,7 @@ describe("legacy branches create integration", () => { if (Exit.isFailure(exit)) { expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); } - }) as Effect.Effect<void>; + }).pipe(Effect.provide(Layer.mergeAll(layer, mockTelemetryRuntime()))); }); }); diff --git a/apps/cli/src/legacy/commands/branches/create/create.live.test.ts b/apps/cli/src/legacy/commands/branches/create/create.live.test.ts index 3a4b1fd4fd..49479dfe2a 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- live tests use Vitest's Promise callback surface to drive the real CLI. import { randomUUID } from "node:crypto"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/branches/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/branches/delete/delete.integration.test.ts index 8d35a52365..6af738b598 100644 --- a/apps/cli/src/legacy/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/delete/delete.integration.test.ts @@ -1,8 +1,5 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -36,6 +33,9 @@ const BRANCH_CONFIG = { }; const tempRoot = useLegacyTempWorkdir("supabase-branches-delete-int-"); +const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)); +const join = pathService.join; +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); interface SetupOpts { readonly deleteStatus?: number; @@ -119,7 +119,7 @@ describe("legacy branches delete integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesDeleteUnexpectedStatusError"); expect(json).toContain("unexpected delete branch status 500"); } @@ -187,18 +187,20 @@ describe("legacy branches delete integration", () => { // the branch's OWN ref, but linked-project.json still holds the real // parent — `branches delete` must resolve the parent for the name // lookup, not the branch ref sitting in project-ref. - mkdirSync(join(tempRoot.current, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(tempRoot.current, "supabase", ".temp", "project-ref"), BRANCH_OWN_REF); - writeFileSync( - join(tempRoot.current, "supabase", ".temp", "linked-project.json"), - JSON.stringify({ - ref: PARENT_REF, - name: "Parent Project", - organization_id: "org_1", - organization_slug: "acme", - }), - ); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = join(tempRoot.current, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(join(tempDir, "project-ref"), BRANCH_OWN_REF); + yield* fs.writeFileString( + join(tempDir, "linked-project.json"), + stringifyJson({ + ref: PARENT_REF, + name: "Parent Project", + organization_id: "org_1", + organization_slug: "acme", + }), + ); yield* legacyBranchesDelete({ ...baseFlags, name: Option.some("my-feature") }); const lookup = api.requests.find( (r) => r.method === "GET" && r.url.includes("/branches/my-feature"), diff --git a/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts b/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts index 3bd193d878..364bae78ba 100644 --- a/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts +++ b/apps/cli/src/legacy/commands/branches/delete/delete.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- live tests use Vitest's Promise callback surface to drive the real CLI. import { randomUUID } from "node:crypto"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/branches/disable/disable.integration.test.ts b/apps/cli/src/legacy/commands/branches/disable/disable.integration.test.ts index 9857a552d9..11f97e026c 100644 --- a/apps/cli/src/legacy/commands/branches/disable/disable.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/disable/disable.integration.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -72,7 +74,7 @@ describe("legacy branches disable integration", () => { const exit = yield* Effect.exit(legacyBranchesDisable(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesDisableUnexpectedStatusError"); expect(json).toContain("unexpected disable branching status 500"); } diff --git a/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts b/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts index 9a9d02e7e8..e2426bbb7e 100644 --- a/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts @@ -5,7 +5,9 @@ import type { V1GetProjectApiKeysOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -290,7 +292,7 @@ describe("legacy branches get integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesPrimaryNotFoundError"); expect(json).toContain("primary database not found"); } @@ -305,7 +307,7 @@ describe("legacy branches get integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesFindUnexpectedStatusError"); expect(json).toContain("unexpected find branch status 404"); } @@ -320,7 +322,7 @@ describe("legacy branches get integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesGetUnexpectedStatusError"); expect(json).toContain("unexpected get branch status 503"); } @@ -335,7 +337,7 @@ describe("legacy branches get integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesApiKeysUnexpectedStatusError"); } }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts b/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts index 63e7849739..f27a1ee50d 100644 --- a/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/list/list.integration.test.ts @@ -1,9 +1,6 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -40,6 +37,9 @@ const SAMPLE_BRANCH_PIPE: Branches[number] = { }; const tempRoot = useLegacyTempWorkdir("supabase-branches-list-int-"); +const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)); +const join = pathService.join; +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); // Distinct 20-lowercase-letter refs used across the parent-scoped resolution // tests below (CLI-2167 follow-up), so it's unambiguous which candidate a @@ -55,24 +55,27 @@ function tempFile(workdir: string, name: string): string { return join(workdir, "supabase", ".temp", name); } -function writeTempContent(workdir: string, name: string, content: string): void { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(tempFile(workdir, name), content); +function writeTempContent(workdir: string, name: string, content: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString(tempFile(workdir, name), content); + }); } // Seeds `supabase/.temp/project-ref` — the 3rd-priority parent candidate, and // (pre-CLI-2167-follow-up) the ONLY thing `branches` subcommands read. -function writeProjectRefFile(workdir: string, ref: string): void { - writeTempContent(workdir, "project-ref", ref); +function writeProjectRefFile(workdir: string, ref: string) { + return writeTempContent(workdir, "project-ref", ref); } // Seeds `supabase/.temp/linked-project.json` — the 2nd-priority parent // candidate, written by `link`'s own success path only for a REAL project. -function writeLinkedProjectCacheFile(workdir: string, ref: string): void { - writeTempContent( +function writeLinkedProjectCacheFile(workdir: string, ref: string) { + return writeTempContent( workdir, "linked-project.json", - JSON.stringify({ + stringifyJson({ ref, name: "Parent Project", organization_id: "org_1", @@ -288,7 +291,7 @@ describe("legacy branches list integration", () => { const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesEnvNotSupportedError"); expect(json).toContain("--output env flag is not supported"); } @@ -335,9 +338,9 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, BRANCH_OWN_REF); - writeLinkedProjectCacheFile(workdir, PARENT_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* legacyBranchesList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); @@ -354,9 +357,9 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, BRANCH_OWN_REF); - writeLinkedProjectCacheFile(workdir, PARENT_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* legacyBranchesList({ projectRef: Option.some(EXPLICIT_REF) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${EXPLICIT_REF}/branches`); }).pipe(Effect.provide(layer)); @@ -368,9 +371,9 @@ describe("legacy branches list integration", () => { projectId: Option.some(ENV_REF), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, BRANCH_OWN_REF); - writeLinkedProjectCacheFile(workdir, CACHE_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, CACHE_REF); yield* legacyBranchesList({ projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${ENV_REF}/branches`); }).pipe(Effect.provide(layer)); @@ -387,9 +390,9 @@ describe("legacy branches list integration", () => { projectId: Option.some(BRANCH_OWN_REF), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, BRANCH_OWN_REF); - writeLinkedProjectCacheFile(workdir, PARENT_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* legacyBranchesList({ projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); }).pipe(Effect.provide(layer)); @@ -408,13 +411,13 @@ describe("legacy branches list integration", () => { projectId: Option.some("not-a-valid-ref"), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, FILE_ONLY_REF); - writeLinkedProjectCacheFile(workdir, CACHE_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, FILE_ONLY_REF); + yield* writeLinkedProjectCacheFile(workdir, CACHE_REF); const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(stringifyJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -429,7 +432,7 @@ describe("legacy branches list integration", () => { const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(stringifyJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -442,7 +445,7 @@ describe("legacy branches list integration", () => { const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -461,12 +464,12 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH], }); - writeLinkedProjectCacheFile(workdir, PARENT_REF); return Effect.gen(function* () { + yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -480,8 +483,8 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, FILE_ONLY_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, FILE_ONLY_REF); yield* legacyBranchesList({ projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${FILE_ONLY_REF}/branches`); }).pipe(Effect.provide(layer)); @@ -502,8 +505,8 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH, OTHER_BRANCH], }); - writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); yield* legacyBranchesList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("feat-1 (active)"); expect(out.stdoutText).not.toContain("other (active)"); @@ -518,8 +521,8 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); yield* legacyBranchesList({ projectRef: Option.none() }); expect(out.stdoutText).not.toContain("active"); }).pipe(Effect.provide(layer)); @@ -534,8 +537,8 @@ describe("legacy branches list integration", () => { projectId: Option.none(), response: [SAMPLE_BRANCH], }); - writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, SAMPLE_BRANCH.project_ref); yield* legacyBranchesList({ projectRef: Option.none() }); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toEqual({ branches: [SAMPLE_BRANCH] }); @@ -561,7 +564,7 @@ describe("legacy branches list integration", () => { const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesListUnexpectedStatusError"); expect(json).toContain("unexpected list branch status 503"); } @@ -574,7 +577,7 @@ describe("legacy branches list integration", () => { const exit = yield* Effect.exit(legacyBranchesList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesListNetworkError"); expect(json).toContain("failed to list branch"); } diff --git a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts index 8ac0529fac..cfdd7296df 100644 --- a/apps/cli/src/legacy/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/branches/list/list.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- live tests use Vitest's Promise callback surface to drive the real CLI. import { randomUUID } from "node:crypto"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/branches/pause/pause.integration.test.ts b/apps/cli/src/legacy/commands/branches/pause/pause.integration.test.ts index 07bb203154..5b084e54bb 100644 --- a/apps/cli/src/legacy/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/pause/pause.integration.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -160,7 +162,7 @@ describe("legacy branches pause integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesPauseUnexpectedStatusError"); expect(json).toContain("unexpected pause branch status 500"); } diff --git a/apps/cli/src/legacy/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/legacy/commands/branches/unpause/unpause.integration.test.ts index 1256cca8d9..5f1e21051c 100644 --- a/apps/cli/src/legacy/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/unpause/unpause.integration.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -71,7 +73,7 @@ describe("legacy branches unpause integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesUnpauseUnexpectedStatusError"); expect(json).toContain("unexpected unpause branch status 500"); } diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 3ca72620ff..222cfe86e0 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -80,16 +80,14 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function Effect.gen(function* () { const mapped = yield* Effect.flip(mapUpdateError(cause)); if (mapped._tag === "LegacyBranchesUpdateUnexpectedStatusError") { - return yield* Effect.fail( - new LegacyBranchesUpdateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - }), - ); + return yield* new LegacyBranchesUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }), ), ), diff --git a/apps/cli/src/legacy/commands/branches/update/update.integration.test.ts b/apps/cli/src/legacy/commands/branches/update/update.integration.test.ts index 374151a3f0..cb35c2ae66 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.integration.test.ts @@ -1,6 +1,8 @@ import { type V1UpdateABranchConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -229,7 +231,7 @@ describe("legacy branches update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyBranchesUpdateUnexpectedStatusError"); expect(json).toContain("unexpected update branch status 500"); } diff --git a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts index 2008210ee4..d2c482c394 100644 --- a/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/bash/bash.integration.test.ts @@ -60,7 +60,7 @@ describe("legacy completion bash", () => { "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); @@ -79,7 +79,7 @@ describe("legacy completion bash", () => { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["bash"]); const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); expect(event).toBeDefined(); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts index 748f82b153..6f53d6ea70 100644 --- a/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts +++ b/apps/cli/src/legacy/commands/completion/completion.e2e.test.ts @@ -12,28 +12,25 @@ describe("supabase completion (legacy)", () => { test( "bash --no-descriptions is accepted and produces the native no-descriptions script", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["completion", "bash", "--no-descriptions"], { + () => + runSupabase(["completion", "bash", "--no-descriptions"], { entrypoint: "legacy", - }); - expect(exitCode).toBe(0); - expect(stdout).toContain("__completeNoDesc"); - }, + }).then(({ exitCode, stdout }) => { + expect(exitCode).toBe(0); + expect(stdout).toContain("__completeNoDesc"); + }), ); // Minimal cross-shell smoke coverage: proves the default (with-descriptions) // code path also works end-to-end through a real subprocess, for a shell // other than bash. - test( - "zsh with no flags produces the native default script", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["completion", "zsh"], { - entrypoint: "legacy", - }); + test("zsh with no flags produces the native default script", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["completion", "zsh"], { + entrypoint: "legacy", + }).then(({ exitCode, stdout }) => { expect(exitCode).toBe(0); expect(stdout).toContain("#compdef supabase"); expect(stdout).toContain("__complete"); - }, + }), ); }); diff --git a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts index 9c1322bc3e..0d41bb0e64 100644 --- a/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/fish/fish.integration.test.ts @@ -60,7 +60,7 @@ describe("legacy completion fish", () => { "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); @@ -79,7 +79,7 @@ describe("legacy completion fish", () => { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["fish"]); const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); expect(event).toBeDefined(); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts index 122b73bd1a..ec2c8cbd5b 100644 --- a/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts +++ b/apps/cli/src/legacy/commands/completion/legacy-completion-scripts.unit.test.ts @@ -1,6 +1,7 @@ -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { type LegacyCompletionShell, @@ -9,8 +10,12 @@ import { const fixturesDir = fileURLToPath(new URL("./__fixtures__", import.meta.url)); -function readFixture(shell: LegacyCompletionShell, variant: "desc" | "nodesc"): string { - return readFileSync(`${fixturesDir}/${shell}.${variant}.txt`, "utf8"); +function readFixture(shell: LegacyCompletionShell, variant: "desc" | "nodesc") { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(fixturesDir, `${shell}.${variant}.txt`)); + }).pipe(Effect.provide(BunServices.layer)); } describe("legacyGenerateCompletionScript", () => { @@ -131,15 +136,23 @@ describe("legacyGenerateCompletionScript", () => { const shells: ReadonlyArray<LegacyCompletionShell> = ["bash", "zsh", "fish", "powershell"]; for (const shell of shells) { - it(`matches the real cobra ${shell} completion script byte-for-byte (with descriptions)`, () => { - const generated = legacyGenerateCompletionScript(shell, { noDescriptions: false }); - expect(generated).toBe(readFixture(shell, "desc")); - }); - - it(`matches the real cobra ${shell} completion script byte-for-byte (--no-descriptions)`, () => { - const generated = legacyGenerateCompletionScript(shell, { noDescriptions: true }); - expect(generated).toBe(readFixture(shell, "nodesc")); - }); + it.effect( + `matches the real cobra ${shell} completion script byte-for-byte (with descriptions)`, + () => + Effect.gen(function* () { + const generated = legacyGenerateCompletionScript(shell, { noDescriptions: false }); + expect(generated).toBe(yield* readFixture(shell, "desc")); + }), + ); + + it.effect( + `matches the real cobra ${shell} completion script byte-for-byte (--no-descriptions)`, + () => + Effect.gen(function* () { + const generated = legacyGenerateCompletionScript(shell, { noDescriptions: true }); + expect(generated).toBe(yield* readFixture(shell, "nodesc")); + }), + ); } }); }); diff --git a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts index 935f8c6dd3..3c97366fbf 100644 --- a/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/powershell/powershell.integration.test.ts @@ -62,7 +62,7 @@ describe("legacy completion powershell", () => { "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); @@ -81,7 +81,7 @@ describe("legacy completion powershell", () => { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["powershell"]); const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); expect(event).toBeDefined(); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts index bb6531eef1..c7caec0137 100644 --- a/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts +++ b/apps/cli/src/legacy/commands/completion/zsh/zsh.integration.test.ts @@ -60,7 +60,7 @@ describe("legacy completion zsh", () => { "--no-descriptions", ]); expect(out.stdoutText).toContain("__completeNoDesc"); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); @@ -79,7 +79,7 @@ describe("legacy completion zsh", () => { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["zsh"]); const event = analytics.captured.find((entry) => entry.event === EventCommandExecuted); expect(event).toBeDefined(); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts index a99bf8112e..d119815d21 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.ts @@ -10,6 +10,7 @@ */ import type { ProjectConfig } from "@supabase/config"; +import { DateTime } from "effect"; import { diff } from "./config-sync.diff.ts"; import { type TomlField, type TomlValue, encodeToml } from "./config-sync.toml.ts"; @@ -2287,7 +2288,7 @@ export function diffAuth(remoteCompare: AuthSubset, local: AuthSubset): string { * Port of Go `(*auth).ToUpdateAuthConfigBody`. * Returns a flat record whose keys are the snake_case API field names. */ -export function authToUpdateBody(local: AuthSubset): RemoteAuthUpdateBody { +export function authToUpdateBody(local: AuthSubset, now: DateTime.Utc): RemoteAuthUpdateBody { const body: Record<string, unknown> = {}; body["site_url"] = local.site_url; @@ -2490,9 +2491,7 @@ export function authToUpdateBody(local: AuthSubset): RemoteAuthUpdateBody { // 10-year validity: calendar-exact, so leap days are counted (a flat // 3650-day offset would be 2-3 days short). setUTCFullYear keeps UTC // semantics. - const validUntil = new Date(); - validUntil.setUTCFullYear(validUntil.getUTCFullYear() + 10); - body["sms_test_otp_valid_until"] = validUntil.toISOString(); + body["sms_test_otp_valid_until"] = DateTime.formatIso(DateTime.add(now, { years: 10 })); } switch (true) { diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts index cdf1f10319..5596324ad1 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/auth.sync.unit.test.ts @@ -12,7 +12,7 @@ */ import { V1UpdateAuthServiceConfigInput } from "@supabase/api/effect"; -import { Exit } from "effect"; +import { DateTime, Exit } from "effect"; import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; @@ -25,6 +25,7 @@ import { } from "./auth.sync.ts"; const lines = (...l: ReadonlyArray<string>) => l.join("\n") + "\n"; +const authNow = DateTime.makeUnsafe("2024-01-01T00:00:00Z"); /** Mirror of Go `newWithDefaults()` projected to AuthSubset. */ function bareAuth(overrides: Partial<AuthSubset> = {}): AuthSubset { @@ -1365,7 +1366,7 @@ describe("authToUpdateBody secrets", () => { providers: { github: "my-github-plaintext" }, }, }); - const body = authToUpdateBody(local); + const body = authToUpdateBody(local, authNow); expect(body["security_captcha_secret"]).toBe("my-captcha-plaintext"); expect(body["external_github_secret"]).toBe("my-github-plaintext"); expect(body["security_captcha_secret"]).not.toContain("hash:"); @@ -1377,7 +1378,7 @@ describe("authToUpdateBody secrets", () => { enabled: true, captcha: { enabled: true, provider: "hcaptcha", secret: "" }, }); - const body = authToUpdateBody(local); + const body = authToUpdateBody(local, authNow); expect("security_captcha_secret" in body).toBe(false); }); @@ -1397,7 +1398,7 @@ describe("authToUpdateBody secrets", () => { providers: {}, }, }); - const body = authToUpdateBody(local); + const body = authToUpdateBody(local, authNow); expect("security_captcha_secret" in body).toBe(false); expect(Object.values(body)).not.toContain("encrypted:BvEYU1pXk9ciphertext"); }); @@ -1407,16 +1408,10 @@ describe("authToUpdateBody secrets", () => { enabled: true, sms: { ...bareAuth().sms, test_otp: { "123456": "654321" } }, }); - const body = authToUpdateBody(local); - const validUntil = new Date(String(body["sms_test_otp_valid_until"])); - // Recompute the expected value the same way the handler does; allow a small - // delta for the clock advancing between the two `new Date()` calls. - const expected = new Date(); - expected.setUTCFullYear(expected.getUTCFullYear() + 10); - expect(Math.abs(validUntil.getTime() - expected.getTime())).toBeLessThan(5_000); - // Flat 3650-day arithmetic would be ~2-3 days short of the calendar value. - const flat3650 = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000; - expect(validUntil.getTime() - flat3650).toBeGreaterThan(24 * 60 * 60 * 1000); + const body = authToUpdateBody(local, authNow); + expect(body["sms_test_otp_valid_until"]).toBe( + DateTime.formatIso(DateTime.add(authNow, { years: 10 })), + ); }); }); @@ -1464,7 +1459,8 @@ describe("password_required_characters mapping", () => { (req, apiValue) => { // local enum → update body (Go ToChar) expect( - authToUpdateBody(bareAuth({ password_requirements: req })).password_required_characters, + authToUpdateBody(bareAuth({ password_requirements: req }), authNow) + .password_required_characters, ).toBe(apiValue); // remote API value → local enum (Go NewPasswordRequirement) expect( @@ -1478,6 +1474,7 @@ describe("password_required_characters mapping", () => { for (const [req] of cases) { const value = authToUpdateBody( bareAuth({ password_requirements: req }), + authNow, ).password_required_characters; const decoded = Schema.decodeUnknownExit(V1UpdateAuthServiceConfigInput)( { ref: "a".repeat(20), password_required_characters: value }, diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts index 4faa92ccaa..af6b6f2263 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.ts @@ -7,9 +7,13 @@ */ import type { ProjectConfig } from "@supabase/config"; +import { Data, Effect, FileSystem, Path } from "effect"; import { legacyResolveNotificationContentPath } from "../../../../shared/legacy-config-validate.ts"; -import { readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../../shared/telemetry/error-actionability.ts"; type AuthEmail = ProjectConfig["auth"]["email"]; @@ -28,6 +32,14 @@ const EMPTY_AUTH_EMAIL_CONTENT: AuthEmailContent = { notification: {}, }; +export class LegacyAuthEmailContentError extends Data.TaggedError("LegacyAuthEmailContentError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** * Reads a template HTML file and wraps filesystem errors in Go-shaped messages. * @@ -38,16 +50,19 @@ const EMPTY_AUTH_EMAIL_CONTENT: AuthEmailContent = { * @throws When the file cannot be read. */ function readTemplateContent( + fileSystem: FileSystem.FileSystem, kind: "template" | "notification", name: string, resolvedPath: string, -): string { - try { - return readFileSync(resolvedPath, "utf8"); - } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - throw new Error(`Invalid config for auth.email.${kind}.${name}.content_path: ${message}`); - } +): Effect.Effect<string, LegacyAuthEmailContentError> { + return fileSystem.readFileString(resolvedPath).pipe( + Effect.mapError( + (cause) => + new LegacyAuthEmailContentError({ + message: `Invalid config for auth.email.${kind}.${name}.content_path: ${cause.message}`, + }), + ), + ); } /** @@ -62,34 +77,33 @@ function readTemplateContent( * nothing was configured or all `content_path` values were empty. * @throws When a configured `content_path` points to a missing or unreadable file. */ -export function loadAuthEmailContent(cwd: string, email: AuthEmail): AuthEmailContent { +export const loadAuthEmailContent = Effect.fnUntraced(function* (cwd: string, email: AuthEmail) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const template: Record<string, string> = {}; const notification: Record<string, string> = {}; for (const [name, tmpl] of Object.entries(email.template)) { const contentPath = tmpl.content_path ?? ""; - if (contentPath.length === 0) { - continue; - } - const resolved = isAbsolute(contentPath) ? contentPath : join(cwd, contentPath); - template[name] = readTemplateContent("template", name, resolved); + if (contentPath.length === 0) continue; + const resolved = path.isAbsolute(contentPath) ? contentPath : path.join(cwd, contentPath); + template[name] = yield* readTemplateContent(fileSystem, "template", name, resolved); } for (const [name, notif] of Object.entries(email.notification)) { - if (!notif.enabled) { - continue; - } + if (!notif.enabled) continue; const contentPath = notif.content_path ?? ""; - if (contentPath.length === 0) { - continue; - } - const resolved = legacyResolveNotificationContentPath(cwd, contentPath); - notification[name] = readTemplateContent("notification", name, resolved); + if (contentPath.length === 0) continue; + const resolved = yield* legacyResolveNotificationContentPath( + path, + fileSystem, + cwd, + contentPath, + ); + notification[name] = yield* readTemplateContent(fileSystem, "notification", name, resolved); } - if (Object.keys(template).length === 0 && Object.keys(notification).length === 0) { - return EMPTY_AUTH_EMAIL_CONTENT; - } - - return { template, notification }; -} + return Object.keys(template).length === 0 && Object.keys(notification).length === 0 + ? EMPTY_AUTH_EMAIL_CONTENT + : { template, notification }; +}); diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts index fddf8d8ab7..306138f3a0 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/config-sync.auth-email-content.unit.test.ts @@ -1,13 +1,13 @@ -/** - * Unit tests for config-sync.auth-email-content.ts. - */ +/** Unit tests for config-sync.auth-email-content.ts. */ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { afterEach, describe, expect, it } from "vitest"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Scope } from "effect"; -import { loadAuthEmailContent } from "./config-sync.auth-email-content.ts"; +import { + LegacyAuthEmailContentError, + loadAuthEmailContent, +} from "./config-sync.auth-email-content.ts"; const emptyEmail = { enable_signup: true, @@ -21,159 +21,228 @@ const emptyEmail = { notification: {}, }; -describe("loadAuthEmailContent", () => { - let workdir = ""; - - afterEach(() => { - if (workdir.length > 0) { - rmSync(workdir, { recursive: true, force: true }); - workdir = ""; - } - }); - - function setup(): { cwd: string; supabaseDir: string } { - workdir = mkdtempSync(join(tmpdir(), "auth-email-content-")); - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - return { cwd: workdir, supabaseDir }; - } - - it("loads templates and notifications from the same project-root base", () => { - const { cwd, supabaseDir } = setup(); - const templateDir = join(supabaseDir, "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "invite.html"), "<h1>Invite</h1>"); - writeFileSync(join(templateDir, "password_changed.html"), "<p>Changed</p>"); - - const content = loadAuthEmailContent(cwd, { - ...emptyEmail, - template: { - invite: { - subject: "You are invited", - content_path: "./supabase/templates/invite.html", - }, - }, - notification: { - password_changed: { - enabled: true, - subject: "Password changed", - content_path: "./supabase/templates/password_changed.html", - }, - }, - }); - - expect(content.template["invite"]).toBe("<h1>Invite</h1>"); - expect(content.notification["password_changed"]).toBe("<p>Changed</p>"); - }); - - it("falls back to the legacy supabase-relative notification path", () => { - const { cwd, supabaseDir } = setup(); - const templateDir = join(supabaseDir, "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "password_changed.html"), "<p>Legacy location</p>"); - - const content = loadAuthEmailContent(cwd, { - ...emptyEmail, - notification: { - password_changed: { - enabled: true, - subject: "Password changed", - content_path: "./templates/password_changed.html", - }, - }, - }); - - expect(content.notification["password_changed"]).toBe("<p>Legacy location</p>"); - }); - - it("falls back when the root-resolved path is a directory, not a file", () => { - const { cwd, supabaseDir } = setup(); - mkdirSync(join(cwd, "templates", "n.html"), { recursive: true }); - mkdirSync(join(supabaseDir, "templates"), { recursive: true }); - writeFileSync(join(supabaseDir, "templates", "n.html"), "<p>Legacy file</p>"); - - const content = loadAuthEmailContent(cwd, { - ...emptyEmail, - notification: { - password_changed: { - enabled: true, - subject: "s", - content_path: "./templates/n.html", - }, - }, - }); - - expect(content.notification["password_changed"]).toBe("<p>Legacy file</p>"); - }); - - it("prefers the project-root notification path over the legacy fallback", () => { - const { cwd, supabaseDir } = setup(); - mkdirSync(join(cwd, "templates"), { recursive: true }); - mkdirSync(join(supabaseDir, "templates"), { recursive: true }); - writeFileSync(join(cwd, "templates", "n.html"), "<p>Root</p>"); - writeFileSync(join(supabaseDir, "templates", "n.html"), "<p>Legacy</p>"); - - const content = loadAuthEmailContent(cwd, { - ...emptyEmail, - notification: { - password_changed: { - enabled: true, - subject: "s", - content_path: "./templates/n.html", - }, - }, - }); - - expect(content.notification["password_changed"]).toBe("<p>Root</p>"); - }); - - it("skips notification templates when disabled", () => { - const { cwd } = setup(); - - const content = loadAuthEmailContent(cwd, { - ...emptyEmail, - notification: { - password_changed: { - enabled: false, - subject: "Password changed", - content_path: "./password_changed.html", - }, - }, - }); - - expect(content.notification).toEqual({}); - }); +type Fixture = { + readonly cwd: string; + readonly supabaseDir: string; + readonly path: Path.Path; + readonly makeDirectory: (directory: string) => Effect.Effect<void, LegacyAuthEmailContentError>; + readonly writeFileString: ( + file: string, + content: string, + ) => Effect.Effect<void, LegacyAuthEmailContentError>; +}; - it("skips entries with an empty content_path", () => { - const { cwd } = setup(); - - const content = loadAuthEmailContent(cwd, { - ...emptyEmail, - template: { - invite: { - subject: "You are invited", - content_path: "", - }, - }, - }); - - expect(content.template).toEqual({}); - expect(content.notification).toEqual({}); +const setup: Effect.Effect< + Fixture, + LegacyAuthEmailContentError, + FileSystem.FileSystem | Path.Path | Scope.Scope +> = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "auth-email-content-" }); + const supabaseDir = path.join(cwd, "supabase"); + yield* fileSystem.makeDirectory(supabaseDir, { recursive: true }); + const mapError = (cause: { readonly message: string }) => + new LegacyAuthEmailContentError({ message: cause.message }); + const fixture: Fixture = { + cwd, + supabaseDir, + path, + makeDirectory: (directory: string) => + fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError(mapError)), + writeFileString: (file: string, content: string) => + fileSystem.writeFileString(file, content).pipe(Effect.mapError(mapError)), + }; + return fixture; +}).pipe(Effect.mapError((cause) => new LegacyAuthEmailContentError({ message: cause.message }))); + +const withSetup = ( + f: ( + fixture: Effect.Success<typeof setup>, + ) => Effect.Effect<unknown, LegacyAuthEmailContentError, FileSystem.FileSystem | Path.Path>, +): Effect.Effect< + unknown, + LegacyAuthEmailContentError, + FileSystem.FileSystem | Path.Path | Scope.Scope +> => { + const program: Effect.Effect< + unknown, + LegacyAuthEmailContentError, + FileSystem.FileSystem | Path.Path | Scope.Scope + > = Effect.gen(function* () { + const fixture = yield* setup; + return yield* f(fixture); }); + return program; +}; - it("throws a Go-shaped error when a template file is missing", () => { - const { cwd } = setup(); +const fileSystemLayer = Layer.mergeAll(BunFileSystem.layer, BunPath.layer); - expect(() => - loadAuthEmailContent(cwd, { - ...emptyEmail, - template: { - invite: { - subject: "You are invited", - content_path: "./templates/missing.html", +describe("loadAuthEmailContent", () => { + it.effect("loads templates and notifications from the same project-root base", () => + withSetup(({ cwd, supabaseDir, makeDirectory, writeFileString, path }) => + Effect.gen(function* () { + const templateDir = path.join(supabaseDir, "templates"); + yield* makeDirectory(templateDir); + yield* writeFileString(path.join(templateDir, "invite.html"), "<h1>Invite</h1>"); + yield* writeFileString(path.join(templateDir, "password_changed.html"), "<p>Changed</p>"); + const content = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + template: { + invite: { + subject: "You are invited", + content_path: "./supabase/templates/invite.html", + }, + }, + notification: { + password_changed: { + enabled: true, + subject: "Password changed", + content_path: "./supabase/templates/password_changed.html", + }, }, - }, + }); + expect(content.template["invite"]).toBe("<h1>Invite</h1>"); + expect(content.notification["password_changed"]).toBe("<p>Changed</p>"); }), - ).toThrow(/^Invalid config for auth\.email\.template\.invite\.content_path:/); - }); + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect("falls back to the legacy supabase-relative notification path", () => + withSetup(({ cwd, supabaseDir, makeDirectory, writeFileString, path }) => + Effect.gen(function* () { + const templateDir = path.join(supabaseDir, "templates"); + yield* makeDirectory(templateDir); + yield* writeFileString( + path.join(templateDir, "password_changed.html"), + "<p>Legacy location</p>", + ); + const content = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + notification: { + password_changed: { + enabled: true, + subject: "Password changed", + content_path: "./templates/password_changed.html", + }, + }, + }); + expect(content.notification["password_changed"]).toBe("<p>Legacy location</p>"); + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect( + "reports the canonical project-root path when both notification paths are missing", + () => + withSetup(({ cwd, path }) => + Effect.gen(function* () { + const result = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + notification: { + password_changed: { + enabled: true, + subject: "Password changed", + content_path: "./templates/missing.html", + }, + }, + }).pipe(Effect.catchTag("LegacyAuthEmailContentError", (error) => Effect.succeed(error))); + + expect(result).toBeInstanceOf(LegacyAuthEmailContentError); + if (result instanceof LegacyAuthEmailContentError) { + expect(result.message).toContain( + `Invalid config for auth.email.notification.password_changed.content_path:`, + ); + expect(result.message).toContain(path.join(cwd, "templates", "missing.html")); + expect(result.message).not.toContain( + path.join(cwd, "supabase", "templates", "missing.html"), + ); + } + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect("falls back when the root-resolved path is a directory, not a file", () => + withSetup(({ cwd, supabaseDir, makeDirectory, writeFileString, path }) => + Effect.gen(function* () { + yield* makeDirectory(path.join(cwd, "templates", "n.html")); + yield* makeDirectory(path.join(supabaseDir, "templates")); + yield* writeFileString(path.join(supabaseDir, "templates", "n.html"), "<p>Legacy file</p>"); + const content = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + notification: { + password_changed: { enabled: true, subject: "s", content_path: "./templates/n.html" }, + }, + }); + expect(content.notification["password_changed"]).toBe("<p>Legacy file</p>"); + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect("prefers the project-root notification path over the legacy fallback", () => + withSetup(({ cwd, supabaseDir, makeDirectory, writeFileString, path }) => + Effect.gen(function* () { + yield* makeDirectory(path.join(cwd, "templates")); + yield* makeDirectory(path.join(supabaseDir, "templates")); + yield* writeFileString(path.join(cwd, "templates", "n.html"), "<p>Root</p>"); + yield* writeFileString(path.join(supabaseDir, "templates", "n.html"), "<p>Legacy</p>"); + const content = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + notification: { + password_changed: { enabled: true, subject: "s", content_path: "./templates/n.html" }, + }, + }); + expect(content.notification["password_changed"]).toBe("<p>Root</p>"); + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect("skips notification templates when disabled", () => + withSetup(({ cwd }) => + Effect.gen(function* () { + const content = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + notification: { + password_changed: { + enabled: false, + subject: "Password changed", + content_path: "./password_changed.html", + }, + }, + }); + expect(content.notification).toEqual({}); + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect("skips entries with an empty content_path", () => + withSetup(({ cwd }) => + Effect.gen(function* () { + const content = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + template: { invite: { subject: "You are invited", content_path: "" } }, + }); + expect(content.template).toEqual({}); + expect(content.notification).toEqual({}); + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); + + it.effect("fails with a Go-shaped error when a template file is missing", () => + withSetup(({ cwd }) => + Effect.gen(function* () { + const result = yield* loadAuthEmailContent(cwd, { + ...emptyEmail, + template: { + invite: { subject: "You are invited", content_path: "./templates/missing.html" }, + }, + }).pipe(Effect.catchTag("LegacyAuthEmailContentError", (error) => Effect.succeed(error))); + expect(result).toMatchObject({ + message: expect.stringMatching( + /^Invalid config for auth\.email\.template\.invite\.content_path:/, + ), + }); + }), + ).pipe(Effect.provide(fileSystemLayer)), + ); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts index 4f4a2ea92d..0ff38c4c4e 100644 --- a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -56,24 +56,23 @@ export const getCostMatrix = Effect.fn("legacy.config.push.cost-matrix")(functio if (response.status !== 200) { const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); const body = sanitizeLegacyErrorBody(rawBody); - return yield* Effect.fail( - new LegacyConfigPushListAddonsStatusError({ - status: response.status, - body, - message: `unexpected list addons status ${response.status}: ${body}`, - }), - ); + return yield* new LegacyConfigPushListAddonsStatusError({ + status: response.status, + body, + message: `unexpected list addons status ${response.status}: ${body}`, + }); } const rawBody = yield* response.text; - const parsed = yield* Effect.try({ - try: () => JSON.parse(rawBody) as unknown, - catch: (cause) => - new LegacyConfigPushListAddonsNetworkError({ - message: `failed to list addons: ${String(cause)}`, - decode: true, - }), - }); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(rawBody).pipe( + Effect.mapError( + (cause) => + new LegacyConfigPushListAddonsNetworkError({ + message: `failed to list addons: ${String(cause)}`, + decode: true, + }), + ), + ); const costMatrix = new Map<string, LegacyCostItem>(); for (const addon of readAddons(parsed)) { diff --git a/apps/cli/src/legacy/commands/config/push/push.e2e.test.ts b/apps/cli/src/legacy/commands/config/push/push.e2e.test.ts index 78381d1484..5022caed74 100644 --- a/apps/cli/src/legacy/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.e2e.test.ts @@ -1,6 +1,5 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { runSupabase } from "../../../../../tests/helpers/cli.ts"; @@ -20,25 +19,37 @@ describe("supabase config push (legacy)", () => { let projectDir: string; beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-config-push-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync(join(projectDir, "supabase", "config.toml"), "malformed"); + return Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + projectDir = yield* fs.makeTempDirectory({ prefix: "supabase-config-push-e2e-" }); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectDir, "supabase", "config.toml"), "malformed"); + }).pipe(Effect.provide(BunServices.layer)), + ); }); afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); + return Effect.runPromise( + FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.remove(projectDir, { recursive: true })), + Effect.provide(BunServices.layer), + ), + ); }); test( "aborts with exit 1 on a malformed config.toml before any network call", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["config", "push", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy", cwd: projectDir, env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("config.toml"); - }, + () => + runSupabase(["config", "push", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + cwd: projectDir, + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("config.toml"); + }), ); }); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index 569bcac0bd..3374bbf894 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,6 +1,5 @@ -import { dirname } from "node:path"; -import { findProjectRoot, loadProjectConfig } from "@supabase/config"; -import { Effect, FileSystem, Path } from "effect"; +import { findProjectRoot, loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { ConfigProvider, DateTime, Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -8,6 +7,7 @@ import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-proje import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { collectConfigEnvironment } from "../../../../shared/runtime/config-environment.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { legacyAssertDecryptableSecrets, @@ -93,6 +93,8 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const runtimeInfo = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const provider = yield* ConfigProvider.ConfigProvider; + const shellEnv = yield* collectConfigEnvironment(provider); // `--yes` OR `SUPABASE_YES`. `config push` imports `supabase/.env` before // the confirmation prompt reads the yes flag, so a `SUPABASE_YES` set only // in `supabase/.env` auto-confirms. Resolve against the project env, not @@ -101,12 +103,16 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // before config load), so a push from a subdirectory still reads the // project root's `supabase/.env`. const projectRoot = (yield* findProjectRoot(runtimeInfo.cwd)) ?? runtimeInfo.cwd; - const projectEnv = yield* legacyLoadProjectEnv(fs, path, projectRoot); - const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const projectEnvValues = yield* legacyLoadProjectEnv(fs, path, projectRoot); + const configProjectEnv = yield* loadProjectEnvironment({ + cwd: projectRoot, + baseEnv: { ...projectEnvValues, ...shellEnv }, + }); + const yes = yield* legacyResolveYesWithProjectEnv(projectEnvValues); // dotenvx private keys for decrypting `encrypted:` secrets, from the shell // + project env — same source/precedence as `legacy-db-config.toml-read.ts` - // (`process.env` wins over `supabase/.env`). - const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); + // (the injected shell environment wins over `supabase/.env`). + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnvValues, ...shellEnv }); // Only reached by `legacyAssertDecryptableSecrets` below for an `env(VAR)` literal that // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. // when this wider env source resolves `VAR` but `@supabase/config`'s @@ -115,7 +121,7 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // non-secret fields; kept for parity with the shared function's other caller // (`legacy-db-config.toml-read.ts`, whose pre-interpolation document relies on this). const secretEnvLookup = (name: string): string | undefined => - process.env[name] ?? projectEnv[name]; + shellEnv[name] ?? projectEnvValues[name]; const ref = yield* resolver.resolve(flags.projectRef); @@ -133,19 +139,17 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( // an established error message. const loaded = yield* loadProjectConfig(runtimeInfo.cwd, { projectRef: ref, + projectEnv: configProjectEnv ?? undefined, goViperCompat: true, }).pipe( - Effect.catchTag( - "ProjectConfigParseError", - (cause) => + Effect.catchTags({ + ProjectConfigParseError: (cause) => new LegacyConfigPushLoadConfigError({ message: `failed to parse supabase/config.toml: ${String(cause.cause)}`, }), - ), - Effect.catchTag( - "DuplicateRemoteProjectIdError", - (cause) => new LegacyConfigPushLoadConfigError({ message: cause.message }), - ), + DuplicateRemoteProjectIdError: (cause) => + new LegacyConfigPushLoadConfigError({ message: cause.message }), + }), ); if (loaded === null) { return yield* new LegacyConfigPushLoadConfigError({ @@ -194,17 +198,15 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( const presence = legacyPresenceIn(loaded.document); // Config lives at <projectRoot>/supabase/config.{toml,json}. - const projectRoot = dirname(dirname(loaded.path)); + const projectRoot = path.dirname(path.dirname(loaded.path)); // Email content validation runs during config load, before any network call. const authEmailContent = authEnabled(config) - ? yield* Effect.try({ - try: () => loadAuthEmailContent(projectRoot, config.auth.email), - catch: (cause) => - new LegacyConfigPushLoadConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }) + ? yield* loadAuthEmailContent(projectRoot, config.auth.email).pipe( + Effect.mapError( + (cause) => new LegacyConfigPushLoadConfigError({ message: cause.message }), + ), + ) : { template: {}, notification: {} }; // 2. Cost matrix (drives cost-aware prompts). @@ -434,16 +436,21 @@ export const legacyConfigPush = Effect.fn("legacy.config.push")(function* ( } else { yield* output.raw(`Updating Auth service with config: ${d}\n`, "stderr"); if (yield* keep("auth")) { - yield* api.v1.updateAuthServiceConfig({ ref, ...authToUpdateBody(local) }).pipe( - Effect.catch( - mapLegacyHttpError({ - networkError: LegacyConfigPushAuthUpdateNetworkError, - statusError: LegacyConfigPushAuthUpdateStatusError, - networkMessage: (cause) => `failed to update Auth config: ${cause}`, - statusMessage: readStatusMessage, - }), - ), - ); + yield* api.v1 + .updateAuthServiceConfig({ + ref, + ...authToUpdateBody(local, yield* DateTime.now), + }) + .pipe( + Effect.catch( + mapLegacyHttpError({ + networkError: LegacyConfigPushAuthUpdateNetworkError, + statusError: LegacyConfigPushAuthUpdateStatusError, + networkMessage: (cause) => `failed to update Auth config: ${cause}`, + statusMessage: readStatusMessage, + }), + ), + ); services.push({ service: "auth", status: "updated" }); } else { services.push({ service: "auth", status: "skipped" }); diff --git a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts index 8edb2a1772..4508dd4cd3 100644 --- a/apps/cli/src/legacy/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/config/push/push.integration.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -23,6 +21,34 @@ import { legacyConfigPush } from "./push.handler.ts"; const tempRoot = useLegacyTempWorkdir("supabase-config-push-int-"); +const fixturePath = Effect.runSync(Effect.provide(Path.Path, Path.layer)); +const join = fixturePath.join; +const pendingFixtureDirectories = new Set<string>(); +const pendingFixtureWrites = new Map<string, string>(); + +function mkdirSync(path: string, _options?: { readonly recursive?: boolean }): void { + pendingFixtureDirectories.add(path); +} + +function writeFileSync(path: string, contents: string): void { + pendingFixtureWrites.set(path, contents); +} + +function flushFixtureFiles() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const directory of pendingFixtureDirectories) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + for (const [path, contents] of pendingFixtureWrites) { + yield* fs.makeDirectory(fixturePath.dirname(path), { recursive: true }); + yield* fs.writeFileString(path, contents); + } + pendingFixtureDirectories.clear(); + pendingFixtureWrites.clear(); + }); +} + function writeConfig(toml: string): void { const dir = join(tempRoot.current, "supabase"); mkdirSync(dir, { recursive: true }); @@ -35,24 +61,6 @@ const DOTENVX_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d7 const DOTENVX_ENCRYPTED_VALUE = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; -/** Save/restore `DOTENV_PRIVATE_KEY` around a test — mirrors the SUPABASE_YES pattern below. */ -function withDotenvPrivateKey<A, E, R>( - value: string | undefined, - effect: Effect.Effect<A, E, R>, -): Effect.Effect<A, E, R> { - const prev = process.env["DOTENV_PRIVATE_KEY"]; - if (value === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = value; - return effect.pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = prev; - }), - ), - ); -} - // Schema-valid PostgREST GET response with the api disabled remotely (empty // schema). The real API client validates GET bodies against the generated // output schema, so every postgrest GET must carry these fields. @@ -88,6 +96,8 @@ function setup(opts: { readonly pipedAnswers?: ReadonlyArray<string>; /** Working directory the handler runs from; defaults to the temp project root. */ readonly runtimeCwd?: string; + /** Explicit shell environment for Config/LegacyViperEnv in this scenario. */ + readonly env?: Record<string, string>; }) { writeConfig(opts.toml); const routes = opts.routes ?? {}; @@ -137,21 +147,26 @@ function setup(opts: { }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const runtimeLayer = buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), + env: opts.env, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), + }).pipe(Layer.tap((context) => flushFixtureFiles().pipe(Effect.provideContext(context)))); const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api, - cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), - telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, - tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), - }), + runtimeLayer, mockStdin( opts.stdinIsTty ?? true, opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, ), Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), ); return { layer, out, api, telemetry, linkedProjectCache }; } @@ -180,6 +195,25 @@ const STORAGE_CONFIG_WITHOUT_POOL_MODE = { }; describe("legacy config push integration", () => { + it.live("resolves numeric config fields from the injected shell environment", () => { + const { layer, out } = setup({ + toml: `${API_ONLY_TOML} +[api] +port = "env(SHELL_API_PORT)" +`, + env: { SHELL_API_PORT: "65432" }, + yes: true, + routes: { + postgrestGet: { status: 200, body: POSTGREST_DISABLED }, + postgresGet: { status: 200, body: {} }, + }, + }); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Pushing config to project:"); + }).pipe(Effect.provide(layer)); + }); + it.live("pushes local config (text, Go parity) and surfaces a PATCH failure", () => { const { layer, out } = setup({ toml: API_ONLY_TOML, @@ -381,8 +415,6 @@ project_id = "abcdefghijklmnopqrst" // `config push` imports `supabase/.env` before the confirmation prompt, // so a project-local `SUPABASE_YES=true` auto-confirms before stdin is // read — the push proceeds despite the piped `n`. - const prev = process.env["SUPABASE_YES"]; - delete process.env["SUPABASE_YES"]; const { layer, api } = setup({ toml: API_ONLY_TOML, stdinIsTty: false, @@ -399,15 +431,7 @@ project_id = "abcdefghijklmnopqrst" expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); it.live("loads config-push env from the project root when run from a subdirectory", () => { @@ -415,8 +439,6 @@ project_id = "abcdefghijklmnopqrst" // SUPABASE_YES in <root>/supabase/.env auto-confirms even when invoked // from a subdir. The env load must walk up like loadProjectConfig, not // use the raw cwd. - const prev = process.env["SUPABASE_YES"]; - delete process.env["SUPABASE_YES"]; const sub = join(tempRoot.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); const { layer, api } = setup({ @@ -436,15 +458,7 @@ project_id = "abcdefghijklmnopqrst" expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); it.live("emits a structured summary in json mode without prompts", () => { @@ -582,25 +596,31 @@ function setupService(opts: { readonly yes?: boolean; readonly confirm?: ReadonlyArray<boolean>; readonly runtimeCwd?: string; + readonly env?: Record<string, string>; }) { writeConfig(opts.toml); const out = mockOutput({ format: "text", promptConfirmResponses: opts.confirm }); const apiMock = mockLegacyPlatformApiService({ v1: { ...baseStubs, ...opts.v1 } }); const telemetry = mockLegacyTelemetryStateTracked(); const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const runtimeLayer = buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: addonsHttpLayer() }, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), + env: opts.env, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + // Gated-service prompts model an interactive user answering via `confirm`. + tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }), + }).pipe(Layer.tap((context) => flushFixtureFiles().pipe(Effect.provideContext(context)))); const layer = Layer.mergeAll( - buildLegacyTestRuntime({ - out, - api: { layer: apiMock.layer, httpClientLayer: addonsHttpLayer() }, - cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), - runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), - telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, - // Gated-service prompts model an interactive user answering via `confirm`. - tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }), - }), + runtimeLayer, mockStdin(true), Layer.succeed(LegacyYesFlag, opts.yes ?? false), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), ); return { layer, out, apiMock }; } @@ -763,23 +783,21 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" const { layer, apiMock } = setupService({ toml, yes: true, + env: { DOTENV_PRIVATE_KEY: DOTENVX_PRIVATE_KEY }, v1: { getAuthServiceConfig: () => Effect.succeed({}), updateAuthServiceConfig: () => Effect.succeed({}), }, }); - return withDotenvPrivateKey( - DOTENVX_PRIVATE_KEY, - Effect.gen(function* () { - yield* legacyConfigPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); - expect(update).toBeDefined(); - const input = update?.input as Record<string, unknown>; - // Go decrypts before hashing/pushing — the plaintext goes to the API, - // never the dotenvx ciphertext. - expect(input["security_captcha_secret"]).toBe("value"); - }).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + yield* legacyConfigPush({ projectRef: Option.none() }); + const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); + expect(update).toBeDefined(); + const input = update?.input as Record<string, unknown>; + // Go decrypts before hashing/pushing — the plaintext goes to the API, + // never the dotenvx ciphertext. + expect(input["security_captcha_secret"]).toBe("value"); + }).pipe(Effect.provide(layer)); }, ); @@ -801,20 +819,17 @@ provider = "hcaptcha" secret = "${DOTENVX_ENCRYPTED_VALUE}" `; const { layer, api } = setup({ toml, yes: true }); - return withDotenvPrivateKey( - undefined, - Effect.gen(function* () { - const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => - Effect.succeed(error.message), - ), - ); - expect(message).toBe("failed to parse config: missing private key"); - // The guard runs during config load, before any network call — not - // even the cost-matrix (list-addons) request that normally runs first. - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + // The guard runs during config load, before any network call — not + // even the cost-matrix (list-addons) request that normally runs first. + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }, ); @@ -835,18 +850,15 @@ enabled = false openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" `; const { layer, api } = setup({ toml, yes: true }); - return withDotenvPrivateKey( - undefined, - Effect.gen(function* () { - const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => - Effect.succeed(error.message), - ), - ); - expect(message).toBe("failed to parse config: missing private key"); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }, ); @@ -866,18 +878,15 @@ enabled = false my_secret = "${DOTENVX_ENCRYPTED_VALUE}" `; const { layer, api } = setup({ toml, yes: true }); - return withDotenvPrivateKey( - undefined, - Effect.gen(function* () { - const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => - Effect.succeed(error.message), - ), - ); - expect(message).toBe("failed to parse config: missing private key"); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }); it.live( @@ -899,18 +908,15 @@ enabled = false secret = "${DOTENVX_ENCRYPTED_VALUE}" `; const { layer, api } = setup({ toml, yes: true }); - return withDotenvPrivateKey( - undefined, - Effect.gen(function* () { - const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( - Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => - Effect.succeed(error.message), - ), - ); - expect(message).toBe("failed to parse config: missing private key"); - expect(api.requests).toHaveLength(0); - }).pipe(Effect.provide(layer)), - ); + return Effect.gen(function* () { + const message = yield* legacyConfigPush({ projectRef: Option.none() }).pipe( + Effect.catchTag("LegacyConfigPushLoadConfigError", (error) => + Effect.succeed(error.message), + ), + ); + expect(message).toBe("failed to parse config: missing private key"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.format.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.format.ts index bcc52130fe..1597a2add3 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.format.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.format.ts @@ -39,8 +39,15 @@ export interface LegacyAdvisorLint { readonly cacheKey: string; } -const asString = (value: unknown): string => - value === null || value === undefined ? "" : String(value); +const asString = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; const asStringArray = (value: unknown): ReadonlyArray<string> => Array.isArray(value) ? value.map(asString) : []; diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts index c58c6758b5..e3f9d92430 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.handler.ts @@ -161,12 +161,10 @@ const runLinked = Effect.fnUntraced(function* ( ), ); if (Option.isNone(tokenOpt)) { - return yield* Effect.fail( - new LegacyDbAdvisorsNotLoggedInError({ - message: legacyMissingAccessTokenMessage(), - suggestion: loginSuggestion(), - }), - ); + return yield* new LegacyDbAdvisorsNotLoggedInError({ + message: legacyMissingAccessTokenMessage(), + suggestion: loginSuggestion(), + }); } const lints: Array<LegacyAdvisorLint> = []; @@ -210,7 +208,7 @@ const outputAndCheck = Effect.fnUntraced(function* ( // Echoes the raw `--fail-on` flag value. const message = `fail-on is set to ${failOn}, non-zero exit`; if (output.format === "text") { - return yield* Effect.fail(new LegacyDbAdvisorsFailOnError({ message })); + return yield* new LegacyDbAdvisorsFailOnError({ message }); } yield* processControl.setExitCode(1); } @@ -225,11 +223,9 @@ const runAdvisors = Effect.fnUntraced(function* ( // explicitly-set flags, not the `--local` default value. const setFlags = target.setFlags; if (setFlags.length > 1) { - return yield* Effect.fail( - new LegacyDbAdvisorsMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbAdvisorsMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }); } // `--project-ref` never implies `--linked` and must not be silently @@ -237,12 +233,10 @@ const runAdvisors = Effect.fnUntraced(function* ( // for the full TS-only rationale. advisors defaults to the local/db-url path // (`runLocal`) whenever `--linked` isn't the resolved target selector. if (Option.isSome(flags.projectRef) && target.connType !== "linked") { - return yield* Effect.fail( - new LegacyDbAdvisorsMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbAdvisorsMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const advisorType = Option.getOrElse(flags.type, () => "all"); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts index c2165fcff8..cb043ad5d9 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Redacted, Schema } from "effect"; import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import { @@ -51,6 +51,8 @@ const LOCAL_CONN: LegacyPgConnInput = { }; const [SETUP_SQL, QUERY_SQL] = splitLegacyLintsSql(); +const stringifyJson = (value: unknown): string => + Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(value); /** A local lint row keyed by the column names the `lints.sql` query aliases. */ function lintRow(over: Partial<Record<string, unknown>> = {}) { @@ -76,12 +78,10 @@ function mockResolver(opts: { ipv6Error?: boolean } = {}) { Effect.gen(function* () { resolveFlags.push(flags); if (opts.ipv6Error === true) { - return yield* Effect.fail( - new LegacyDbConfigIpv6Error({ - message: "IPv6 is not supported on your current network", - suggestion: "Run supabase link --project-ref abc to setup IPv4 connection.", - }), - ); + return yield* new LegacyDbConfigIpv6Error({ + message: "IPv6 is not supported on your current network", + suggestion: "Run supabase link --project-ref abc to setup IPv4 connection.", + }); } return { conn: LOCAL_CONN, @@ -365,7 +365,7 @@ describe("legacy db advisors — local", () => { const exit = yield* Effect.exit(legacyDbAdvisors(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to prepare lint session"); + expect(stringifyJson(exit.cause)).toContain("failed to prepare lint session"); } }).pipe(Effect.provide(layer)); }); @@ -376,7 +376,7 @@ describe("legacy db advisors — local", () => { const exit = yield* Effect.exit(legacyDbAdvisors(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to query lints"); + expect(stringifyJson(exit.cause)).toContain("failed to query lints"); } }).pipe(Effect.provide(layer)); }); @@ -392,9 +392,9 @@ describe("legacy db advisors — local", () => { const failure = Cause.findErrorOption(exit.cause); if (Option.isSome(failure)) { expect(failure.value).toBeInstanceOf(LegacyDbAdvisorsFailOnError); - expect((failure.value as LegacyDbAdvisorsFailOnError).message).toBe( - "fail-on is set to error, non-zero exit", - ); + if (failure.value instanceof LegacyDbAdvisorsFailOnError) { + expect(failure.value.message).toBe("fail-on is set to error, non-zero exit"); + } } } }).pipe(Effect.provide(layer)); @@ -412,9 +412,9 @@ describe("legacy db advisors — local", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); if (Option.isSome(failure)) { - expect((failure.value as LegacyDbAdvisorsFailOnError).message).toBe( - "fail-on is set to warn, non-zero exit", - ); + if (failure.value instanceof LegacyDbAdvisorsFailOnError) { + expect(failure.value.message).toBe("fail-on is set to warn, non-zero exit"); + } } } }).pipe(Effect.provide(layer)); @@ -428,7 +428,7 @@ describe("legacy db advisors — local", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(stringifyJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be", ); } @@ -505,7 +505,7 @@ describe("legacy db advisors — local", () => { const exit = yield* Effect.exit(legacyDbAdvisors(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(stringifyJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); } @@ -598,7 +598,7 @@ describe("legacy db advisors — linked", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(stringifyJson(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } @@ -638,7 +638,7 @@ describe("legacy db advisors — linked", () => { const exit = yield* Effect.exit(legacyDbAdvisors(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("IPv6 is not supported"); + expect(stringifyJson(exit.cause)).toContain("IPv6 is not supported"); } expect(api.requests).toHaveLength(0); // Cache written despite the DB-config failure (ref was loaded first). @@ -703,10 +703,10 @@ describe("legacy db advisors — linked", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(LegacyDbAdvisorsNotLoggedInError); - const error = failure.value as LegacyDbAdvisorsNotLoggedInError; - expect(error.message).toContain("Access token not provided"); - expect(error.suggestion).toContain("supabase login"); + if (failure.value instanceof LegacyDbAdvisorsNotLoggedInError) { + expect(failure.value.message).toContain("Access token not provided"); + expect(failure.value.suggestion).toContain("supabase login"); + } } } }).pipe(Effect.provide(layer)); @@ -720,10 +720,10 @@ describe("legacy db advisors — linked", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(LegacyDbAdvisorsInvalidTokenError); - const error = failure.value as LegacyDbAdvisorsInvalidTokenError; - expect(error.message).toContain("Invalid access token format"); - expect(error.suggestion).toContain("supabase login"); + if (failure.value instanceof LegacyDbAdvisorsInvalidTokenError) { + expect(failure.value.message).toContain("Invalid access token format"); + expect(failure.value.suggestion).toContain("supabase login"); + } } } // The token gate fails before any advisors request is made. @@ -739,7 +739,7 @@ describe("legacy db advisors — linked", () => { const exit = yield* Effect.exit(legacyDbAdvisors(flags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("unexpected security advisors status 200"); + expect(stringifyJson(exit.cause)).toContain("unexpected security advisors status 200"); } }).pipe(Effect.provide(layer)); }); @@ -750,7 +750,7 @@ describe("legacy db advisors — linked", () => { const exit = yield* Effect.exit(legacyDbAdvisors(flags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("unexpected security advisors status 500"); + expect(stringifyJson(exit.cause)).toContain("unexpected security advisors status 500"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts index 9466fd5d87..29d4c05b38 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -78,7 +78,7 @@ const fetchAdvisors = Effect.fnUntraced(function* ( if (response.status !== 200) { const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail(endpoint.status(response.status, sanitizeLegacyErrorBody(rawBody))); + return yield* endpoint.status(response.status, sanitizeLegacyErrorBody(rawBody)); } // The 200 body is only decoded when the Content-Type header contains "json"; @@ -87,15 +87,18 @@ const fetchAdvisors = Effect.fnUntraced(function* ( const contentType = response.headers["content-type"] ?? ""; if (!contentType.toLowerCase().includes("json")) { const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail(endpoint.status(200, sanitizeLegacyErrorBody(rawBody))); + return yield* endpoint.status(200, sanitizeLegacyErrorBody(rawBody)); } const rawBody = yield* response.text; // A decode error folds into the same `failed to fetch … advisors: %w` path, // so map both JSON syntax errors and structural-shape rejections (thrown by // `apiResponseToLegacyAdvisorLints`) to the endpoint's network error. + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(rawBody).pipe( + Effect.mapError((cause) => endpoint.network(String(cause), { decode: true })), + ); return yield* Effect.try({ - try: () => apiResponseToLegacyAdvisorLints(JSON.parse(rawBody) as unknown), + try: () => apiResponseToLegacyAdvisorLints(decoded), catch: (cause) => endpoint.network(String(cause), { decode: true }), }); }); diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts index cafdc486f9..6b44cca721 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.lints-sql.ts @@ -1,4 +1,3 @@ -/* eslint-disable */ /** * `lints.sql` embedded verbatim, an established output contract. Stored as a * JSON-encoded string literal so the bytes stay byte-identical and immune to diff --git a/apps/cli/src/legacy/commands/db/db.command.ts b/apps/cli/src/legacy/commands/db/db.command.ts index 454e01de4e..4728f2a501 100644 --- a/apps/cli/src/legacy/commands/db/db.command.ts +++ b/apps/cli/src/legacy/commands/db/db.command.ts @@ -1,3 +1,4 @@ +import { BunServices } from "@effect/platform-bun"; import { Command } from "effect/unstable/cli"; import { legacyDbDiffCommand } from "./diff/diff.command.ts"; import { legacyDbDumpCommand } from "./dump/dump.command.ts"; @@ -12,6 +13,7 @@ import { legacyDbTestCommand } from "./test/test.command.ts"; import { legacyDbBranchCommand } from "./branch/branch.command.ts"; import { legacyDbRemoteCommand } from "./remote/remote.command.ts"; import { legacyDbSchemaCommand } from "./schema/schema.command.ts"; +import { legacyLocalGatewayHttpClientLayer } from "../../shared/legacy-local-gateway-http-client.ts"; export const legacyDbCommand = Command.make("db").pipe( Command.withDescription("Manage Postgres databases."), @@ -31,4 +33,6 @@ export const legacyDbCommand = Command.make("db").pipe( legacyDbRemoteCommand.pipe(Command.unlisted), legacyDbSchemaCommand, ]), + Command.provide(legacyLocalGatewayHttpClientLayer), + Command.provide(BunServices.layer), ); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts index 2d8a044702..4d0ed35793 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.declarative.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this e2e test drives the real CLI and inspects host files. import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { afterEach, expect, test } from "vitest"; diff --git a/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts index 2bab7a8eeb..17c7b0afbc 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.e2e.test.ts @@ -11,14 +11,14 @@ describe("supabase db diff (legacy)", () => { test( "--from without --to exits non-zero with the explicit-mode error", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["db", "diff", "--from", "local"], { + () => + runSupabase(["db", "diff", "--from", "local"], { entrypoint: "legacy", - }); - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain( - "must set both --from and --to when using explicit diff mode", - ); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain( + "must set both --from and --to when using explicit diff mode", + ); + }), ); }); diff --git a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts index ed9bebe00a..9dcbf453f4 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.handler.ts @@ -159,22 +159,18 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy if (Option.isSome(flags.usePgSchema)) engineSet.push("use-pg-schema"); if (Option.isSome(flags.usePgDelta)) engineSet.push("use-pg-delta"); if (engineSet.length > 1) { - return yield* Effect.fail( - new LegacyDbDiffEngineConflictError({ - message: `if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [${[...engineSet].sort().join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbDiffEngineConflictError({ + message: `if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [${[...engineSet].sort().join(" ")}] were all set`, + }); } const targetSet: Array<string> = []; if (Option.isSome(flags.dbUrl)) targetSet.push("db-url"); if (Option.isSome(flags.linked)) targetSet.push("linked"); if (Option.isSome(flags.local)) targetSet.push("local"); if (targetSet.length > 1) { - return yield* Effect.fail( - new LegacyDbDiffTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbDiffTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, + }); } // Config is read lazily per path, not unconditionally up front: reading the base @@ -192,11 +188,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy const toSet = to.length > 0; if (fromSet || toSet) { if (!fromSet || !toSet) { - return yield* Effect.fail( - new LegacyDbDiffExplicitFlagsError({ - message: "must set both --from and --to when using explicit diff mode", - }), - ); + return yield* new LegacyDbDiffExplicitFlagsError({ + message: "must set both --from and --to when using explicit diff mode", + }); } // `--project-ref` never implies `--linked` and must not be silently // discarded — see push.handler.ts's identical guard for the full TS-only @@ -216,12 +210,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy legacyClassifyExplicitRef(from) !== "linked" && legacyClassifyExplicitRef(to) !== "linked" ) { - return yield* Effect.fail( - new LegacyDbDiffTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", - }), - ); + return yield* new LegacyDbDiffTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", + }); } // `mergedLinkedRef` tracks the linked ref resolved so far (preflight or // cascade) so the config read below + a later `migrations` catalog export @@ -268,12 +260,12 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // Each ref resolves in order; the `linked` branch re-merges the matching // `[remotes.<ref>]` block so a later `local` ref read and the trailing // `pgDeltaFormatOptions()` see the override. Thread the merged config through. - const resolveRef = (ref: string): Effect.Effect<LegacyPgDeltaEndpoint, unknown> => + const resolveRef = (ref: string) => Effect.gen(function* () { switch (legacyClassifyExplicitRef(ref)) { case "local": { const connection = { - host: legacyGetHostname(), + host: yield* legacyGetHostname, port: cfg.port, user: "postgres", password: cfg.password, @@ -327,9 +319,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy connectOptions: { isLocal: false, dnsResolver }, } satisfies LegacyPgDeltaDatabaseEndpoint; default: - return yield* Effect.fail( - new LegacyDbDiffUnknownTargetError({ message: legacyUnknownTargetMessage(ref) }), - ); + return yield* new LegacyDbDiffUnknownTargetError({ + message: legacyUnknownTargetMessage(ref), + }); } }); const source = yield* resolveRef(from); @@ -348,7 +340,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy desired, schema: flags.schema, formatOptions: Option.getOrElse(cfg.pgDelta.formatOptions, () => ""), - debug: legacyIsPgDeltaDebugEnabled(), + debug: legacyIsPgDeltaDebugEnabled(cfg.projectEnv), strictCoverage: flags.strictCoverage, }); // Explicit-mode output: `--output` file, or stdout with no trailing newline @@ -407,11 +399,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // as of CLI-1968 and honors `--project-ref` through this function's own // target resolve, like every other native engine.) if (usePgSchema && Option.isSome(flags.projectRef)) { - return yield* Effect.fail( - new LegacyDbDiffTargetFlagsError({ - message: "--project-ref is not supported with --use-pg-schema", - }), - ); + return yield* new LegacyDbDiffTargetFlagsError({ + message: "--project-ref is not supported with --use-pg-schema", + }); } if (usePgSchema) { // TS-only deprecation notice, printed before delegating (in both text and @@ -455,12 +445,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // earlier guard, with the `--from/--to linked` exception; this native path // never reaches here when explicit mode ran.) if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyDbDiffTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbDiffTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN @@ -490,7 +478,10 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy // Make an allowlisted `supabase/.env` registry override visible to the // synchronous `process.env` reader the pgAdmin differ's (and the migra/pg-delta // shadow's) own image resolver falls back to, reverted when this scope closes. - yield* legacyApplyProjectEnv(cfg.projectEnv); + const effectiveProjectEnv = { + ...cfg.projectEnv, + ...(yield* legacyApplyProjectEnv(cfg.projectEnv)), + }; if (cfg.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${cfg.appliedRemote}]\n`, "stderr"); } @@ -547,7 +538,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(cfg.pgDelta.npmVersion), denoVersion: cfg.denoVersion, - projectEnv: cfg.projectEnv, + projectEnv: effectiveProjectEnv, }; const formatOptions = Option.getOrElse(cfg.pgDelta.formatOptions, () => ""); @@ -623,11 +614,9 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy ), ); if (!running) { - return yield* Effect.fail( - new LegacyDbDiffDbNotRunningError({ - message: `${legacyAqua("supabase start")} is not running.`, - }), - ); + return yield* new LegacyDbDiffDbNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }); } yield* emitStatus("Creating shadow database..."); const shadowBase = yield* resolveShadowRunInput(); @@ -722,7 +711,7 @@ export const legacyDbDiff = Effect.fn("legacy.db.diff")(function* (flags: Legacy }, schema: flags.schema, formatOptions, - debug: legacyIsPgDeltaDebugEnabled(), + debug: legacyIsPgDeltaDebugEnabled(effectiveProjectEnv), strictCoverage: flags.strictCoverage, }); // Keep the per-unit plan files so a multi-unit plan can be written as one diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index 68bf7909ee..933d3bb97b 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -1,8 +1,16 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Fiber, Layer, Option } from "effect"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Option, + Path, + Schema, +} from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -18,6 +26,9 @@ import { useLegacyTempWorkdir, legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; + +const stringifyJson = (value: unknown): string => + Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(value); import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -28,6 +39,7 @@ import { LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; import { @@ -117,6 +129,8 @@ interface SetupOpts { // Makes every differ `runCapture` call fail at the docker boundary instead of // returning a result — `"spawn"` (daemon unreachable) or `"pull"` (registry failure). readonly pgadminDockerFail?: "spawn" | "pull"; + /** Explicit registry value observed by the differ's image resolver seam. */ + readonly pgadminRegistryEnv?: string; // Makes the pre-flight `docker container inspect supabase_db_<projectId>` probe // (`legacyIsLocalDbRunning`, run before `--use-pgadmin` provisions anything) report // "container not found" — surfaces as "supabase start is not running.". @@ -293,10 +307,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { // never teed to the parent terminal (see `legacy-pgadmin-diff.ts`'s own doc // comment). const differCaptureOpts: Array<{ readonly teeStderr?: boolean } | undefined> = []; - // Snapshots `process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]` at the moment each - // differ `runCapture` call is made — the real `legacyDockerRunLayer`'s own image - // resolver reads that key straight off `process.env` at call time (no - // `projectEnvValues` threaded through), so this stands in for it here. + // Snapshots the explicit registry value at the moment each differ `runCapture` + // call is made, matching the resolver's injected project-environment input. const differRegistryEnvAtCall: Array<string | undefined> = []; const shadowSetupJobCalls: Array<{ readonly env: Readonly<Record<string, string>> }> = []; const docker = Layer.succeed(LegacyDockerRun, { @@ -305,7 +317,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { if (dockerOpts.image.includes("pgadmin-schema-diff")) { differCalls.push(dockerOpts); differCaptureOpts.push(captureOpts); - differRegistryEnvAtCall.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + differRegistryEnvAtCall.push(opts.pgadminRegistryEnv); if (opts.pgadminDockerFail !== undefined) { return Effect.fail( new LegacyDockerRunError({ @@ -397,13 +409,18 @@ function setup(workdir: string, opts: SetupOpts = {}) { return opts.delegateStdout ?? ""; }), }); + const configProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); const baseLayer = Layer.mergeAll( // `BunServices.layer` is listed FIRST so every fake service layer below (most // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its // real implementation — `Layer.mergeAll` is last-wins on a shared service, // matching `start.integration.test.ts`'s own established ordering. - BunServices.layer, + BunServices.layer.pipe( + Layer.tap((context) => flushFixtureFiles(workdir).pipe(Effect.provideContext(context))), + ), + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, telemetry.layer, cache.layer, @@ -439,6 +456,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { layer, + configProvider, out, cache, telemetry, @@ -493,6 +511,45 @@ const stderr = (out: ReturnType<typeof mockOutput>) => const tmp = useLegacyTempWorkdir(); +// Fixture writes are queued synchronously at the test declaration site and flushed +// through the Effect FileSystem service when the command layer is provided. This +// keeps the fixture ergonomics while ensuring every actual filesystem operation is +// scoped and typed. +const fixturePath = Effect.runSync(Effect.provide(Path.Path, Path.layer)); +const join = fixturePath.join; +const basename = fixturePath.basename; +const pendingFixtureDirectories = new Map<string, Set<string>>(); +const pendingFixtureWrites = new Map<string, Map<string, string>>(); + +function mkdirSync(path: string, _options?: { readonly recursive?: boolean }): void { + const workdir = tmp.current; + const paths = pendingFixtureDirectories.get(workdir) ?? new Set<string>(); + paths.add(path); + pendingFixtureDirectories.set(workdir, paths); +} + +function writeFileSync(path: string, contents: string): void { + const workdir = tmp.current; + const writes = pendingFixtureWrites.get(workdir) ?? new Map<string, string>(); + writes.set(path, contents); + pendingFixtureWrites.set(workdir, writes); +} + +function flushFixtureFiles(workdir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const directory of pendingFixtureDirectories.get(workdir) ?? []) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + for (const [path, contents] of pendingFixtureWrites.get(workdir) ?? []) { + yield* fs.makeDirectory(fixturePath.dirname(path), { recursive: true }); + yield* fs.writeFileString(path, contents); + } + pendingFixtureDirectories.delete(workdir); + pendingFixtureWrites.delete(workdir); + }); +} + // --- native --use-pgadmin fixtures --- /** `DiffEntry` shape, defaulting to a kept entry. */ @@ -556,7 +613,13 @@ describe("legacy db diff", () => { } } expect(sawHost).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provideService<ConfigProvider.ConfigProvider, ConfigProvider.ConfigProvider>( + ConfigProvider.ConfigProvider, + s.configProvider, + ), + Effect.provide(s.layer), + ); }); it.effect("diffs local with pgdelta when --use-pg-delta is set", () => { @@ -590,32 +653,40 @@ describe("legacy db diff", () => { expect(s.edgeCalls).toEqual([]); expect(stderr(s.out)).toContain("Diffing schemas: public"); expect(stdout(s.out)).toBe("create table p ();\n\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("next local diff ignores schema_paths and declarative files", () => { - mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db.migrations]", - 'schema_paths = ["configured.sql"]', - "", - "[experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); - writeFileSync(join(tmp.current, "supabase", "configured.sql"), "create table configured ();\n"); - writeFileSync( - join(tmp.current, "supabase", "schemas", "ignored.sql"), - "create table ignored ();\n", - ); const s = setup(tmp.current, { pgDeltaImplementation: "next", diffSql: "create table result ();\n", }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["configured.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "configured.sql"), + "create table configured ();\n", + ); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "schemas", "ignored.sql"), + "create table ignored ();\n", + ); yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); expect(s.databaseDiffCalls[0]).not.toHaveProperty("declarativeFiles"); expect(s.databaseDiffCalls[0]).not.toHaveProperty("declarativeManifest"); @@ -634,49 +705,72 @@ describe("legacy db diff", () => { expect(stderr(s.out)).toContain("schema_paths no longer changes the migrations baseline"); expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); expect(stdout(s.out)).toBe("create table result ();\n\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); // The transition warning is only true for the bundled next engine. Every other // engine still routes a local target with declarative files through the // declared-schema `contrib_regression` override, so schema_paths DOES still shape // their output and claiming otherwise would be a lie. - const writeSchemaPathsConfig = (pgDeltaEnabled: boolean) => { - mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db.migrations]", - 'schema_paths = ["configured.sql"]', - "", - "[experimental.pgdelta]", - `enabled = ${pgDeltaEnabled}`, - "", - ].join("\n"), - ); - writeFileSync(join(tmp.current, "supabase", "configured.sql"), "create table configured ();\n"); - }; + const writeSchemaPathsConfig = ( + fs: FileSystem.FileSystem, + path: Path.Path, + pgDeltaEnabled: boolean, + ) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(tmp.current, "supabase", "database"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["configured.sql"]', + "", + "[experimental.pgdelta]", + `enabled = ${pgDeltaEnabled}`, + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "configured.sql"), + "create table configured ();\n", + ); + }); it.effect("legacy pg-delta local diff does not print the schema_paths transition warning", () => { - writeSchemaPathsConfig(true); const s = setup(tmp.current, { pgDeltaImplementation: "legacy", diffSql: "create table result ();\n", }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeSchemaPathsConfig(fs, path, true); yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); expect(stderr(s.out)).not.toContain("schema_paths no longer changes the migrations baseline"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("PG14: provisions a shadow via the SQL-exec init path (no PG15+ one-shot jobs)", () => { // This covers the PG14 branch of the `legacySetupDatabase` pipeline, which execs // SQL directly via the session instead of the three one-shot `LegacyDockerRun` // jobs (the PG15+ short-id DNS resolution path is covered separately). - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 14\n"); const s = setup(tmp.current, { diffSql: "create table pg14 ();\n" }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + "[db]\nmajor_version = 14\n", + ); yield* legacyDbDiff(flags()); expect(stdout(s.out)).toBe("create table pg14 ();\n\n"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); @@ -685,7 +779,10 @@ describe("legacy db diff", () => { // no one-shot `LegacyDockerRun` jobs run for this branch. expect(s.dockerCalls).toEqual([]); expect(s.shadowExecCalls.length).toBeGreaterThan(0); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -699,7 +796,10 @@ describe("legacy db diff", () => { expect(Exit.isFailure(exit)).toBe(true); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); it.effect("a linked [remotes.<ref>] block enabling pg-delta selects the pg-delta engine", () => { @@ -707,31 +807,36 @@ describe("legacy db diff", () => { // experimental.pgdelta.enabled is read. The default db diff target is local (no // merge), so this only applies with --linked; base config disables pg-delta, the // remote override enables it, so the diff must pick the pg-delta engine. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[experimental.pgdelta]", - "enabled = false", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", diffSql: "alter table x;\n", }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = false", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); yield* legacyDbDiff(flags({ linked: Option.some(true) })); expect(s.databaseDiffCalls[0]?.target.connectOptions.isLocal).toBe(false); expect(s.databaseDiffCalls[0]?.source.connectOptions.isLocal).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -745,61 +850,71 @@ describe("legacy db diff", () => { // is the ONLY branch that emits a `--tmpfs` flag on the shadow's `docker create` argv // (`legacyBuildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) // overridden by a remote block's `major_version = 14` must flip that flag on. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db]", - "major_version = 17", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.db]", - "major_version = 14", - "", - ].join("\n"), - ); const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", diffSql: "alter table x;\n", }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ); yield* legacyDbDiff(flags({ linked: Option.some(true) })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); // The PG15+ one-shot platform-baseline jobs (`initSchema15`) never run for PG14 — // it execs SQL directly over the session instead — corroborating the same override. expect(s.dockerCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); it.effect("the base config (default local target) does not merge a remote block", () => { // The default db diff target is local; Go never calls LoadProjectRef for local, // so a [remotes.<ref>] override must be ignored and the base engine (migra) wins. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[experimental.pgdelta]", - "enabled = false", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = false", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); yield* legacyDbDiff(flags()); // The local default never merges a remote block, so the base (migra) engine wins. expect(s.edgeCalls[0]?.script).not.toContain("renderPlanFiles"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("diffs the linked project and writes the linked-project cache", () => { @@ -811,7 +926,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ linked: Option.some(true) })); expect(s.cache.cached).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("diffs the project given via --project-ref without a linked workdir", () => { @@ -827,7 +945,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ linked: Option.some(true), projectRef: Option.some(FLAG_REF) })); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("--project-ref overrides an already-linked workdir's project ref", () => { @@ -844,7 +965,10 @@ describe("legacy db diff", () => { expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); expect(s.cache.cachedRef).not.toBe("abcdefghijklmnopqrst"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("rejects --project-ref combined with an explicit --local target", () => { @@ -855,13 +979,16 @@ describe("legacy db diff", () => { legacyDbDiff(flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) })), ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(stringifyJson(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); // The guard fires before any connection resolution or cache write. expect(s.resolverCalls).toEqual([]); expect(s.cache.cached).toBe(false); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -901,7 +1028,10 @@ describe("legacy db diff", () => { projectRef: "flagflagflagflagflag", }); expect(s.cache.cachedRef).toBe("flagflagflagflagflag"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -944,7 +1074,10 @@ describe("legacy db diff", () => { projectRef: "flagflagflagflagflag", }); expect(s.cache.cachedRef).toBe("flagflagflagflagflag"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -966,11 +1099,14 @@ describe("legacy db diff", () => { ), ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(stringifyJson(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked, or --from/--to linked, in explicit mode", ); expect(s.resolverCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -996,7 +1132,10 @@ describe("legacy db diff", () => { expect(Exit.isFailure(exit)).toBe(true); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1015,7 +1154,10 @@ describe("legacy db diff", () => { // The declarative-schema file was migrated into the contrib_regression override. expect(s.shadowConnectedDatabases).toContain("contrib_regression"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1044,7 +1186,10 @@ describe("legacy db diff", () => { expect(err).not.toContain("Diffing local database with current migrations..."); expect(err).not.toContain("Diffing schemas"); expect(err).not.toContain("Finished"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1060,10 +1205,13 @@ describe("legacy db diff", () => { legacyDbDiff(flags({ usePgSchema: Option.some(true), projectRef: Option.some(FLAG_REF) })), ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("--project-ref is not supported with --use-pg-schema"); + expect(stringifyJson(exit)).toContain("--project-ref is not supported with --use-pg-schema"); expect(s.proxyCalls).toEqual([]); expect(s.proxyCaptureCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("--use-pgadmin --linked honors --project-ref like the other native engines", () => { @@ -1087,7 +1235,10 @@ describe("legacy db diff", () => { expect(s.differCalls).toHaveLength(1); expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe(FLAG_REF); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -1122,7 +1273,10 @@ describe("legacy db diff", () => { expect(stderr(s.out)).toContain("Loading config override: [remotes.staging]"); expect(s.proxyCalls).toEqual([]); expect(s.differCalls).toHaveLength(1); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1158,7 +1312,10 @@ describe("legacy db diff", () => { .map((c) => c.args[2]); expect(inspectTargets).toContain("supabase_db_abcdefghijklmnopqrst"); expect(inspectTargets).not.toContain("supabase_db_test"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1175,7 +1332,10 @@ describe("legacy db diff", () => { expect(Exit.isFailure(exit)).toBe(true); expect(s.resolverCalls).toHaveLength(0); expect(s.differCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1189,7 +1349,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { const exit = yield* legacyDbDiff(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -1224,7 +1387,10 @@ describe("legacy db diff", () => { expect(error.message).toContain("failed to read TLS cert"); } expect(s.resolverCalls).toHaveLength(0); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1239,7 +1405,10 @@ describe("legacy db diff", () => { const args = s.proxyCalls[0]?.args ?? []; const idx = args.indexOf("--schema"); expect(args[idx + 1]).toBe('"tenant,one"'); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -1254,7 +1423,10 @@ describe("legacy db diff", () => { const call = s.differCalls[0]; const idx = call?.cmd.indexOf("--schema") ?? -1; expect(call?.cmd[idx + 1]).toBe("tenant,one"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1277,7 +1449,10 @@ describe("legacy db diff", () => { // The child's own telemetry is disabled so the single `cli_command_executed` // event comes from this TS command's instrumentation, not the delegated child. expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1286,7 +1461,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags()); expect(stderr(s.out)).not.toContain('"--use-pg-schema" is deprecated'); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -1296,7 +1474,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); expect(stderr(s.out)).not.toContain('"--use-pg-schema" is deprecated'); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1328,7 +1509,10 @@ describe("legacy db diff", () => { engine: "pgadmin", dropStatements: [], }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1343,12 +1527,16 @@ describe("legacy db diff", () => { yield* legacyDbDiff( flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), ); + const fs = yield* FileSystem.FileSystem; const success = s.out.messages.find((m) => m.type === "success"); const data = success?.data as { file: string; files: ReadonlyArray<string> }; expect(data.file).toMatch(/\d{14}_pgadmin_diff\.sql$/); expect(data.files).toEqual([data.file]); - expect(existsSync(data.file)).toBe(true); - }).pipe(Effect.provide(s.layer)); + expect(yield* fs.exists(data.file)).toBe(true); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1361,7 +1549,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ diff: PGADMIN_DIFF_SQL, engine: "pgadmin" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("--use-pg-schema in json mode wraps the captured SQL in a structured envelope", () => { @@ -1378,43 +1569,53 @@ describe("legacy db diff", () => { expect(stderr(s.out)).toContain('"--use-pg-schema" is deprecated'); // The child's own telemetry is disabled here too, same as the text-mode delegate. expect(s.proxyCaptureCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("writes live-only SQL with --file even when declarative targets are configured", () => { - mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db.migrations]", - 'schema_paths = ["schemas/*.sql"]', - "", - "[experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); - writeFileSync( - join(tmp.current, "supabase", "schemas", "declarative.sql"), - "create table declarative_only ();\n", - ); const s = setup(tmp.current, { pgDeltaImplementation: "next", diffSql: "create table live_only ();\n", }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + [ + "[db.migrations]", + 'schema_paths = ["schemas/*.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "schemas", "declarative.sql"), + "create table declarative_only ();\n", + ); yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); expect(stdout(s.out)).toBe(""); expect(stderr(s.out)).toContain("schema_paths no longer changes the migrations baseline"); expect(stderr(s.out)).toContain("db diff -f uses supabase/migrations as its baseline"); expect(stderr(s.out)).toContain("-f names the migration; it does not filter objects"); expect(stderr(s.out)).toContain("WARNING: The diff tool is not foolproof"); - const dir = join(tmp.current, "supabase", "migrations"); - const files = readdirSync(dir); + const dir = path.join(tmp.current, "supabase", "migrations"); + const files = yield* fs.readDirectory(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/^\d{14}_my_diff\.sql$/); - expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("create table live_only ();\n"); - }).pipe(Effect.provide(s.layer)); + expect(yield* fs.readFileString(path.join(dir, files[0]!))).toBe( + "create table live_only ();\n", + ); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("includes the ignored declarative baseline advisory in JSON output", () => { @@ -1449,7 +1650,10 @@ describe("legacy db diff", () => { ], }); expect(stderr(s.out)).toContain("db diff -f uses supabase/migrations as its baseline"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("ignores declarative inspection errors without changing diff success", () => { @@ -1477,7 +1681,10 @@ describe("legacy db diff", () => { expect(success?.data).not.toHaveProperty("advisories"); expect(success?.data).toMatchObject({ diff: "create table dogfood_note ();\n" }); expect(stderr(s.out)).not.toContain("db diff -f uses supabase/migrations"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("writes one migration file per unit for a multi-unit pg-delta plan", () => { @@ -1491,17 +1698,24 @@ describe("legacy db diff", () => { }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgDelta: Option.some(true), file: Option.some("my_diff") })); - const dir = join(tmp.current, "supabase", "migrations"); - const files = readdirSync(dir).sort(); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(tmp.current, "supabase", "migrations"); + const files = (yield* fs.readDirectory(dir)).sort(); expect(files).toHaveLength(2); expect(files[0]).toBe("19700101000000_my_diff_1.sql"); expect(files[1]).toBe("19700101000001_my_diff_2.sql"); - expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("alter type mood add value 'ok';\n"); + expect(yield* fs.readFileString(path.join(dir, files[0]!))).toBe( + "alter type mood add value 'ok';\n", + ); const success = s.out.messages.find((m) => m.type === "success"); const data = success?.data as { file: string; files: ReadonlyArray<string> }; expect(data.files).toHaveLength(2); expect(data.file).toBe(data.files[0]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("creates nested parent directories for a nested single-unit --file name", () => { @@ -1510,12 +1724,17 @@ describe("legacy db diff", () => { const s = setup(tmp.current, { diffSql: "create table g ();\n" }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ file: Option.some("snapshots/remote") })); - const migrationsRoot = join(tmp.current, "supabase", "migrations"); - const dirs = readdirSync(migrationsRoot); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsRoot = path.join(tmp.current, "supabase", "migrations"); + const dirs = yield* fs.readDirectory(migrationsRoot); expect(dirs).toHaveLength(1); expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); - expect(readdirSync(join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); - }).pipe(Effect.provide(s.layer)); + expect(yield* fs.readDirectory(path.join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit --from local --to linked prints the diff to stdout", () => { @@ -1547,7 +1766,10 @@ describe("legacy db diff", () => { }); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); expect(stdout(s.out)).toBe("create table e ();\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit URL endpoints retain the raw ref and remote connection options", () => { @@ -1569,7 +1791,10 @@ describe("legacy db diff", () => { ref: "postgresql://desired.example/postgres", connectOptions: { isLocal: false, dnsResolver: "native" }, }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit --output writes raw SQL to the given path", () => { @@ -1582,9 +1807,14 @@ describe("legacy db diff", () => { output: Option.some("out.sql"), }), ); - expect(existsSync(join(tmp.current, "out.sql"))).toBe(true); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.exists(path.join(tmp.current, "out.sql"))).toBe(true); expect(stdout(s.out)).toBe(""); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -1598,7 +1828,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgSchema: Option.some(true), linked: Option.some(false) })); expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema", "--linked=false"]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1612,9 +1845,16 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ file: Option.some("") })); expect(stdout(s.out)).toContain("create table y ();"); - const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); - }).pipe(Effect.provide(s.layer)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(tmp.current, "supabase", "migrations"); + expect( + (yield* fs.exists(migrationsDir)) ? yield* fs.readDirectory(migrationsDir) : [], + ).toEqual([]); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1630,7 +1870,10 @@ describe("legacy db diff", () => { ); // Reaching stdout proves it didn't try to write SQL to the resolved workdir. expect(stdout(s.out)).toBe("create table z ();\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -1640,7 +1883,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ from: Option.some("migrations"), to: Option.some("local") })); expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); expect(s.edgeCalls).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit --from linked --to migrations passes the linked ref to the strategy", () => { @@ -1676,7 +1922,10 @@ describe("legacy db diff", () => { // resolves FIRST here, so the remote-merged config (major_version = 14) is what // must reach the migrations shadow/catalog. expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(14); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit --from migrations --to linked passes base config to the strategy", () => { @@ -1713,7 +1962,10 @@ describe("legacy db diff", () => { expect(s.explicitDiffCalls[0]?.source).toEqual({ kind: "migrations" }); expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(17); expect(s.explicitDiffCalls[0]?.toml?.webhooksEnabled).toBe(false); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit --from local --to migrations --linked seeds the merged config", () => { @@ -1753,7 +2005,10 @@ describe("legacy db diff", () => { kind: "migrations", projectRef: "abcdefghijklmnopqrst", }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit --from local --to migrations --linked validates the merged config", () => { @@ -1789,7 +2044,10 @@ describe("legacy db diff", () => { }), ).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("empty --from/--to (shell vars) fall through to the normal diff", () => { @@ -1801,7 +2059,10 @@ describe("legacy db diff", () => { // Reaching the native path proves it didn't enter explicit mode and error. expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(stdout(s.out)).toBe("create table e ();\n\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("an explicit --from with an empty --to still errors 'must set both'", () => { @@ -1811,7 +2072,10 @@ describe("legacy db diff", () => { flags({ from: Option.some("local"), to: Option.some("") }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("explicit mode still runs the target-flag preflight on a changed --db-url", () => { @@ -1829,7 +2093,10 @@ describe("legacy db diff", () => { }), ); expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "db-url" })); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("fails when --from is set without --to", () => { @@ -1837,7 +2104,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { const exit = yield* legacyDbDiff(flags({ from: Option.some("local") })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("fails on engine-flag conflict (--use-migra with --use-pg-delta)", () => { @@ -1847,7 +2117,10 @@ describe("legacy db diff", () => { flags({ useMigra: Option.some(true), usePgDelta: Option.some(true) }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("fails on target mutex (--linked with --local)", () => { @@ -1857,7 +2130,10 @@ describe("legacy db diff", () => { flags({ linked: Option.some(true), local: Option.some(true) }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("warns on drop statements in the diff", () => { @@ -1866,7 +2142,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags()); expect(stderr(s.out)).toContain("Found drop statements in schema diff"); expect(stderr(s.out)).toContain("drop table gone"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("warns on semantic data-loss hazards without a DROP statement", () => { @@ -1885,7 +2164,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ usePgDelta: Option.some(true) })); expect(stderr(s.out)).toContain("Found destructive changes in schema diff"); expect(stderr(s.out)).toContain(sql); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("emits a json envelope with --output-format json (payload-only stdout)", () => { @@ -1900,7 +2182,10 @@ describe("legacy db diff", () => { file: null, engine: "migra", }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("prints 'No schema changes found' and exits 0 on an empty diff", () => { @@ -1909,7 +2194,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags()); expect(stderr(s.out)).toContain("No schema changes found"); expect(stdout(s.out)).toBe(""); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("surfaces a crashed migra script instead of reporting no schema changes", () => { @@ -1921,7 +2209,10 @@ describe("legacy db diff", () => { const exit = yield* legacyDbDiff(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(stderr(s.out)).not.toContain("No schema changes found"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("falls back to the migra Docker image when edge-runtime OOMs", () => { @@ -1931,7 +2222,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ schema: ["public"] })); expect(s.dockerCalls).toHaveLength(1); expect(stdout(s.out)).toBe("create table fb ();\n\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("the migra OOM fallback honors --network-id over host networking", () => { @@ -1950,7 +2244,10 @@ describe("legacy db diff", () => { _tag: "named", name: "my-net", }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.live( @@ -1971,6 +2268,7 @@ describe("legacy db diff", () => { return Effect.gen(function* () { const fiber = yield* legacyDbDiff(flags()).pipe( Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), Effect.forkChild({ startImmediately: true }), ); // Wait until the shadow's own health check has actually probed the @@ -2005,9 +2303,16 @@ describe("legacy db diff", () => { expect(stdout(s.out)).toBe( "Creating shadow database...\nDiffing local database with current migrations...\n", ); - const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); - }).pipe(Effect.provide(s.layer)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(tmp.current, "supabase", "migrations"); + expect( + (yield* fs.exists(migrationsDir)) ? yield* fs.readDirectory(migrationsDir) : [], + ).toEqual([]); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2020,7 +2325,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); expect(stderr(s.out)).toContain("No schema changes found"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2030,14 +2338,19 @@ describe("legacy db diff", () => { yield* legacyDbDiff( flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; expect(stdout(s.out)).not.toContain("ALTER TABLE"); expect(stderr(s.out)).toContain("WARNING: The diff tool is not foolproof"); - const dir = join(tmp.current, "supabase", "migrations"); - const files = readdirSync(dir); + const dir = path.join(tmp.current, "supabase", "migrations"); + const files = yield* fs.readDirectory(dir); expect(files).toHaveLength(1); expect(files[0]).toMatch(/^\d{14}_pgadmin_diff\.sql$/); - expect(readFileSync(join(dir, files[0]!), "utf8")).toBe(PGADMIN_DIFF_SQL); - }).pipe(Effect.provide(s.layer)); + expect(yield* fs.readFileString(path.join(dir, files[0]!))).toBe(PGADMIN_DIFF_SQL); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("creates nested parent directories for a nested --use-pgadmin --file name", () => { @@ -2046,12 +2359,19 @@ describe("legacy db diff", () => { yield* legacyDbDiff( flags({ usePgAdmin: Option.some(true), file: Option.some("snapshots/remote") }), ); - const migrationsRoot = join(tmp.current, "supabase", "migrations"); - const dirs = readdirSync(migrationsRoot); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsRoot = path.join(tmp.current, "supabase", "migrations"); + const dirs = yield* fs.readDirectory(migrationsRoot); expect(dirs).toHaveLength(1); expect(dirs[0]).toMatch(/^\d{14}_snapshots$/); - expect(readdirSync(join(migrationsRoot, dirs[0]!))).toEqual(["remote.sql"]); - }).pipe(Effect.provide(s.layer)); + expect(yield* fs.readDirectory(path.join(migrationsRoot, dirs[0]!))).toEqual([ + "remote.sql", + ]); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -2061,9 +2381,16 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true), file: Option.some("") })); expect(stdout(s.out)).toContain("ALTER TABLE test;"); - const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(migrationsDir) ? readdirSync(migrationsDir) : []).toEqual([]); - }).pipe(Effect.provide(s.layer)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const migrationsDir = path.join(tmp.current, "supabase", "migrations"); + expect( + (yield* fs.exists(migrationsDir)) ? yield* fs.readDirectory(migrationsDir) : [], + ).toEqual([]); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2078,7 +2405,10 @@ describe("legacy db diff", () => { expect(stderr(s.out)).not.toContain("Finished"); expect(stderr(s.out)).not.toContain("Found drop statements"); expect(stdout(s.out)).toContain("drop table gone;"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2113,7 +2443,10 @@ describe("legacy db diff", () => { // Go never tees the differ's raw stderr to the parent terminal — the // `runCapture` options argument must stay unset. expect(s.differCaptureOpts[0]).toBeUndefined(); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2126,7 +2459,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); const call = s.differCalls[0] as LegacyDockerRunOpts; expect(call.network).toEqual({ _tag: "named", name: "custom-net" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -2140,7 +2476,10 @@ describe("legacy db diff", () => { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); const call = s.differCalls[0] as LegacyDockerRunOpts; expect(call.extraHosts).toEqual([]); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2158,40 +2497,33 @@ describe("legacy db diff", () => { const call = s.differCalls[0] as LegacyDockerRunOpts; expect(call.cmd.at(-1)).toBe(PGADMIN_TARGET_URL); expect(call.cmd.join(" ")).not.toContain("distinctive-pw"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); it.effect( "a supabase/.env-only SUPABASE_INTERNAL_IMAGE_REGISTRY reaches the differ's image resolver during the run, and reverts after", () => { - // `legacyDockerRunLayer`'s own image resolver has no `projectEnvValues` in - // scope, so it falls back to reading `process.env` directly at `runCapture` - // call time; this mock docker layer records that same read - // (`differRegistryEnvAtCall`) since it replaces the real resolver wholesale - // and can't observe an already-rewritten image. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; + // The resolver receives project `.env` values explicitly for this run; no + // ambient shell mutation is needed, and the scoped value does not leak. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", ".env"), "SUPABASE_INTERNAL_IMAGE_REGISTRY=registry.example.com\n", ); - const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); + const s = setup(tmp.current, { + pgadminStdout: [JSON.stringify([pgadminEntry()])], + pgadminRegistryEnv: "registry.example.com", + }); return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); expect(s.differRegistryEnvAtCall).toEqual(["registry.example.com"]); - // Reverted once the handler's scope closes — no leak into a later command - // (or a later test) sharing this process. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), ); }, ); @@ -2212,7 +2544,10 @@ describe("legacy db diff", () => { expect(text).toContain("Diffing 1\n"); expect(text).not.toContain("Starting schema diff..."); expect(text).not.toContain("noise line"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2223,7 +2558,10 @@ describe("legacy db diff", () => { return Effect.gen(function* () { yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })); expect(stdout(s.out)).toContain("ALTER TABLE test;"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -2255,7 +2593,10 @@ describe("legacy db diff", () => { expect(idxPublic).toBeGreaterThanOrEqual(0); expect(idxApp).toBeGreaterThan(idxPublic); expect(text).toContain("create table pub ();"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2289,7 +2630,10 @@ describe("legacy db diff", () => { expect(idxApp).toBeGreaterThan(idxPublic); // Neither run's raw NOTE prefix leaked into the rendered diff. expect(text).not.toContain("NOTE: Configuring authentication for DESKTOP mode."); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2306,7 +2650,10 @@ describe("legacy db diff", () => { expect((error as { message: string }).message).toContain( "failed to parse schema diff output:", ); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -2331,7 +2678,10 @@ describe("legacy db diff", () => { const text = stdout(s.out); expect(text).toContain("Comparing Tables \n"); expect(text).toContain("Diffing 1\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2350,7 +2700,10 @@ describe("legacy db diff", () => { expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "differ" }); expect(stderr(s.out)).toContain("Comparing Tables \n"); expect(stdout(s.out)).toBe(""); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2374,7 +2727,10 @@ describe("legacy db diff", () => { // only ever fed the progress-line filter). expect((error as { message: string }).message).not.toContain("some differ crash text"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2389,7 +2745,10 @@ describe("legacy db diff", () => { reason: "differ", message: "error running container: exit 137", }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("classifies a differ spawn failure as docker_daemon", () => { @@ -2399,7 +2758,10 @@ describe("legacy db diff", () => { Effect.flip, ); expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "docker_daemon" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect("classifies a differ image-pull failure as registry_pull", () => { @@ -2409,7 +2771,10 @@ describe("legacy db diff", () => { Effect.flip, ); expect(error).toMatchObject({ _tag: "LegacyDbDiffPgAdminError", reason: "registry_pull" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }); it.effect( @@ -2430,7 +2795,10 @@ describe("legacy db diff", () => { // resolves the target in the root PersistentPreRunE, strictly before // RunPgAdmin's AssertSupabaseDbIsRunning. expect(s.resolverCalls.length).toBeGreaterThan(0); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2447,7 +2815,10 @@ describe("legacy db diff", () => { ); expect(error).toMatchObject({ _tag: "LegacyDbDiffDbNotRunningError", daemonDown: true }); expect((error as { suggestion?: string }).suggestion).toContain("Docker Desktop"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2462,7 +2833,10 @@ describe("legacy db diff", () => { expect(Exit.isFailure(exit)).toBe(true); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2481,7 +2855,10 @@ describe("legacy db diff", () => { flags({ usePgAdmin: Option.some(true), file: Option.some("pgadmin_diff") }), ).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "LegacyDbDiffWriteError" }); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2496,7 +2873,10 @@ describe("legacy db diff", () => { expect((error as { message: string }).message).toBe( "if any flags in the group [use-migra use-pgadmin use-pg-schema use-pg-delta] are set none of the others can be; [use-pg-delta use-pgadmin] were all set", ); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2513,7 +2893,10 @@ describe("legacy db diff", () => { }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2532,7 +2915,10 @@ describe("legacy db diff", () => { expect(s.differCalls).toEqual([]); expect(s.explicitDiffCalls).toHaveLength(1); expect(stdout(s.out)).toBe("create table explicit ();\n"); - }).pipe(Effect.provide(s.layer)); + }).pipe( + Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), + ); }, ); @@ -2543,6 +2929,7 @@ describe("legacy db diff", () => { return Effect.gen(function* () { const fiber = yield* legacyDbDiff(flags({ usePgAdmin: Option.some(true) })).pipe( Effect.provide(s.layer), + Effect.provideService(ConfigProvider.ConfigProvider, s.configProvider), Effect.forkChild({ startImmediately: true }), ); // Wait for the SHADOW's own health probe specifically (its 64-hex id) — diff --git a/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts index b58d60f79c..c75186d4dd 100644 --- a/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts +++ b/apps/cli/src/legacy/commands/db/diff/legacy-pgadmin-diff.ts @@ -387,12 +387,10 @@ export const legacyDiffSchemaPgAdmin = ( // The differ's own stderr is never surfaced beyond the progress-line // filter above; any non-matching line is silently dropped, even under // `--debug`. - return yield* Effect.fail( - new LegacyDbDiffPgAdminError({ - message: `error running container: exit ${result.exitCode}`, - reason: "differ", - }), - ); + return yield* new LegacyDbDiffPgAdminError({ + message: `error running container: exit ${result.exitCode}`, + reason: "differ", + }); } const stdout = new TextDecoder().decode(result.stdout); // Parsed per run rather than concatenated across runs and parsed once, @@ -401,12 +399,10 @@ export const legacyDiffSchemaPgAdmin = ( // this module's own header comment. const parsed = legacyParsePgAdminDiffEntries(stdout); if (Result.isFailure(parsed)) { - return yield* Effect.fail( - new LegacyDbDiffPgAdminError({ - message: parsed.failure.message, - reason: "invalid_output", - }), - ); + return yield* new LegacyDbDiffPgAdminError({ + message: parsed.failure.message, + reason: "invalid_output", + }); } ddls.push(...parsed.success); } diff --git a/apps/cli/src/legacy/commands/db/dump/dump.command.ts b/apps/cli/src/legacy/commands/db/dump/dump.command.ts index 770f390aa3..d9a16f6809 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.command.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.command.ts @@ -23,7 +23,7 @@ import { legacyDbDumpRuntimeLayer } from "./dump.layers.ts"; const onRunFailure = (error: LegacyDbDumpRunError) => Effect.gen(function* () { const output = yield* Output; - if (output.format === "text") return yield* Effect.fail(error); + if (output.format === "text") return yield* error; const processControl = yield* ProcessControl; yield* output.raw(`${error.message}\n`, "stderr"); yield* processControl.setExitCode(1); diff --git a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts index 17b8c2c5c4..d84123929f 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.handler.ts @@ -81,7 +81,10 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // not apply the env as a side effect of `resolveDbPassword`, so `db dump` opts // in explicitly here. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); - yield* legacyApplyProjectEnv(projectEnv); + const effectiveProjectEnv = { + ...projectEnv, + ...(yield* legacyApplyProjectEnv(projectEnv)), + }; // The grouped boolean flags are modelled as `Option` (presence = explicitly // set) for the mutex/target checks; resolve their effective values here for @@ -95,11 +98,9 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // `--use-copy --data-only=false` passes the check and runs the schema // dump with dataOnly=false. Gate on absence, not the resolved value. if ((flags.useCopy || flags.exclude.length > 0) && Option.isNone(flags.dataOnly)) { - return yield* Effect.fail( - new LegacyDbDumpRequiresDataOnlyError({ - message: `required flag(s) "data-only" not set`, - }), - ); + return yield* new LegacyDbDumpRequiresDataOnlyError({ + message: `required flag(s) "data-only" not set`, + }); } // 2. Mutually-exclusive flag groups. "Set" means explicitly set: an @@ -128,11 +129,9 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy for (const group of LEGACY_DUMP_EXCLUSIVE_GROUPS) { const set = group.filter(isSet); if (set.length > 1) { - return yield* Effect.fail( - new LegacyDbDumpMutuallyExclusiveFlagsError({ - message: cobraMutuallyExclusiveErrorMessage(group, set), - }), - ); + return yield* new LegacyDbDumpMutuallyExclusiveFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(group, set), + }); } } @@ -153,12 +152,10 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // on a non-linked target — one-liner: see push.handler.ts's identical guard // for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyDbDumpMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbDumpMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // The project ref is resolved before the connection is built, and the // linked-project cache is refreshed unconditionally afterward — including on a @@ -289,28 +286,26 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy ? // `--file`: (re)truncate then append-stream. Truncating per attempt // ensures the file ends up holding only the successful attempt's // output when a pooler retry runs. - fs - .writeFile(resolvedFile.value, new Uint8Array(0), { mode: DUMP_FILE_MODE }) - .pipe(Effect.mapError(toOpenFileError)) - .pipe( - Effect.andThen( - Effect.scoped( - Effect.gen(function* () { - const file = yield* fs - .open(resolvedFile.value, { flag: "a" }) - .pipe(Effect.mapError(toOpenFileError)); - return yield* legacyStreamPgDump({ - image, - script: mode.script, - env, - onStdout: (chunk) => - file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), - projectEnvValues: projectEnv, - }); - }), - ), + fs.writeFile(resolvedFile.value, new Uint8Array(0), { mode: DUMP_FILE_MODE }).pipe( + Effect.mapError(toOpenFileError), + Effect.andThen( + Effect.scoped( + Effect.gen(function* () { + const file = yield* fs + .open(resolvedFile.value, { flag: "a" }) + .pipe(Effect.mapError(toOpenFileError)); + return yield* legacyStreamPgDump({ + image, + script: mode.script, + env, + onStdout: (chunk) => + file.writeAll(chunk).pipe(Effect.mapError(toOpenFileError)), + projectEnvValues: effectiveProjectEnv, + }); + }), ), - ) + ), + ) : // stdout: write each chunk straight to stdout (binary-safe, no decode). // On a pooler retry the partial first-attempt bytes are left on // stdout (a pipe can't be rewound); streaming matches that. @@ -319,7 +314,7 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy script: mode.script, env, onStdout: (chunk) => output.rawBytes(chunk), - projectEnvValues: projectEnv, + projectEnvValues: effectiveProjectEnv, }); // 7b. Container-level IPv6 → IPv4-pooler retry, shared with `db pull`. A @@ -364,14 +359,12 @@ export const legacyDbDump = Effect.fn("legacy.db.dump")(function* (flags: Legacy // exposed through the resolver and is left as a follow-up — the // generic hint is restored.) if (result.exitCode !== 0) { - return yield* Effect.fail( - new LegacyDbDumpRunError({ - message: `error running container: exit ${result.exitCode}`, - ...(legacyIsIPv6ConnectivityError(result.stderr) - ? { suggestion: legacyIpv6Suggestion() } - : {}), - }), - ); + return yield* new LegacyDbDumpRunError({ + message: `error running container: exit ${result.exitCode}`, + ...(legacyIsIPv6ConnectivityError(result.stderr) + ? { suggestion: legacyIpv6Suggestion() } + : {}), + }); } // Report the absolute output path on stderr. diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index 3ba6be4f8d..03d477c220 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -1,8 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -16,6 +14,7 @@ import { LegacyDnsResolverFlag, LegacyNetworkIdFlag, } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyInvalidProjectRefError, @@ -196,13 +195,11 @@ function mockDockerRun(opts: { Effect.gen(function* () { allOpts.push(runOpts); if (opts.runFails === true) { - return yield* Effect.fail( - new LegacyDockerRunError({ - message: "failed to run docker: not found", - reason: "spawn", - daemonDown: false, - }), - ); + return yield* new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }); } const next = queue.shift(); const r = next ?? { exitCode: opts.exitCode, stdout: opts.stdout, stderr: opts.stderr }; @@ -251,6 +248,7 @@ interface SetupOpts { } function setup(opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); @@ -270,6 +268,8 @@ function setup(opts: SetupOpts = {}) { const docker = mockDockerRun(opts); const layer = Layer.mergeAll( out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), resolver.layer, projectRef.layer, docker.layer, @@ -287,7 +287,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(LegacyDnsResolverFlag, "native"), BunServices.layer, ); - return { layer, out, telemetry, resolver, docker, cache }; + return { layer, configProvider, out, telemetry, resolver, docker, cache }; } const flags = (over: Partial<LegacyDbDumpFlags> = {}): LegacyDbDumpFlags => ({ @@ -459,9 +459,11 @@ describe("legacy db dump integration", () => { it.live("prints the post-run Dumped-schema message on --dry-run --file without writing", () => { // The file is never opened on dry-run, but `Dumped schema to <abs>.` is // still printed, with no dry-run guard and without touching the file. - const filePath = join(tmp.current, "dry.sql"); const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(tmp.current, "dry.sql"); yield* legacyDbDump( flags({ dryRun: true, local: Option.some(true), file: Option.some(filePath) }), ); @@ -469,7 +471,7 @@ describe("legacy db dump integration", () => { expect(out.stderrText).toContain(`Dumped schema to`); expect(out.stderrText).toContain(filePath); expect(docker.lastOpts).toBeUndefined(); - expect(existsSync(filePath)).toBe(false); + expect(yield* fs.exists(filePath)).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -489,13 +491,15 @@ describe("legacy db dump integration", () => { it.live("validates the merged config before the --dry-run print (Go root PreRun order)", () => { // The merged config is validated before the dump runs, even for // --dry-run, so an invalid config fails without printing. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[remotes.staging]", 'project_id = "staging"', ""].join("\n"), - ); const { layer, out } = setup({ isLocal: true, workdir: tmp.current }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", "config.toml"), + ["[remotes.staging]", 'project_id = "staging"', ""].join("\n"), + ); const exit = yield* legacyDbDump(flags({ dryRun: true, local: Option.some(true) })).pipe( Effect.exit, ); @@ -582,8 +586,12 @@ describe("legacy db dump integration", () => { workdir: tmp.current, }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* legacyDbDump(flags({ local: Option.some(true), file: Option.some("out.sql") })); - expect(readFileSync(join(tmp.current, "out.sql"), "utf8")).toBe("CREATE SCHEMA public;\n"); + expect(yield* fs.readFileString(path.join(tmp.current, "out.sql"))).toBe( + "CREATE SCHEMA public;\n", + ); }).pipe(Effect.provide(layer)); }); @@ -596,28 +604,23 @@ describe("legacy db dump integration", () => { }); it.live( - "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", + "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when no flag is set", () => { // Host networking is the default, but a resolved `--network-id`/`SUPABASE_NETWORK_ID` // value overrides it whenever non-empty — a value sourced only from `supabase/.env` // still wins over host. - const prev = process.env["SUPABASE_NETWORK_ID"]; - delete process.env["SUPABASE_NETWORK_ID"]; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); const { layer, docker } = setup({ isLocal: true, workdir: tmp.current }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tmp.current, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(tmp.current, "supabase", ".env"), + "SUPABASE_NETWORK_ID=dotenv-net\n", + ); yield* legacyDbDump(flags({ local: Option.some(true) })); expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "dotenv-net" }); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; - else process.env["SUPABASE_NETWORK_ID"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }, ); @@ -772,11 +775,13 @@ describe("legacy db dump integration", () => { }); it.live("writes the dump to --file and reports the absolute path on stderr", () => { - const filePath = join(tmp.current, "out.sql"); const { layer, out } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n" }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filePath = path.join(tmp.current, "out.sql"); yield* legacyDbDump(flags({ local: Option.some(true), file: Option.some(filePath) })); - expect(readFileSync(filePath, "utf8")).toBe("CREATE SCHEMA public;\n"); + expect(yield* fs.readFileString(filePath)).toBe("CREATE SCHEMA public;\n"); expect(out.stderrText).toContain(`Dumped schema to`); expect(out.stderrText).toContain(filePath); // Nothing written to stdout in --file mode. diff --git a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts index 8bf824a885..57de185b45 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and inspects host files. import { existsSync } from "node:fs"; import { join } from "node:path"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/db/lint/lint.handler.ts b/apps/cli/src/legacy/commands/db/lint/lint.handler.ts index 4dc733e7c6..0ff0e87f45 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.handler.ts @@ -38,8 +38,15 @@ import { LEGACY_MANAGED_SCHEMAS, } from "./lint.lint-sql.ts"; -const asString = (value: unknown): string => - value === null || value === undefined ? "" : String(value); +const asString = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; /** Lists the user schemas — used when `--schema` is omitted. */ const listUserSchemas = Effect.fnUntraced(function* (session: LegacyDbSession) { @@ -112,23 +119,19 @@ const runLint = Effect.fnUntraced(function* ( // explicitly-set flags, not the `--local` default value. const setFlags = target.setFlags; if (setFlags.length > 1) { - return yield* Effect.fail( - new LegacyDbLintMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbLintMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }); } // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target — see push.handler.ts's identical guard // for the full TS-only rationale. if (Option.isSome(flags.projectRef) && target.connType !== "linked") { - return yield* Effect.fail( - new LegacyDbLintMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbLintMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const level = Option.getOrElse(flags.level, () => "warning"); @@ -208,7 +211,7 @@ const runLint = Effect.fnUntraced(function* ( if (failed) { const message = `fail-on is set to ${LEGACY_LINT_ALLOWED_LEVELS[failOnLevel]}, non-zero exit`; if (output.format === "text") { - return yield* Effect.fail(new LegacyDbLintFailOnError({ message })); + return yield* new LegacyDbLintFailOnError({ message }); } // json / stream-json already emitted the result payload above; signal the // non-zero exit without a second stdout write that would corrupt it. diff --git a/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts b/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts index d69277424e..c636532e76 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; import { @@ -42,12 +42,14 @@ const LOCAL_CONN: LegacyPgConnInput = { const ERROR_ISSUE = { level: "error", message: `record "r" has no field "c"` }; const WARNING_ISSUE = { level: "warning", message: "never read variable" }; +const stringifyJson = (value: unknown): string => + Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(value); /** Builds a plpgsql_check row keyed by the driver's column names. */ function checkRow(proname: string, issues: ReadonlyArray<Record<string, unknown>>) { return { proname, - plpgsql_check_function: JSON.stringify({ function: proname, issues }), + plpgsql_check_function: stringifyJson({ function: proname, issues }), }; } @@ -235,7 +237,7 @@ describe("legacy db lint", () => { return Effect.gen(function* () { yield* legacyDbLint(flags({ schema: ["public"] })); const expected = encodeLegacyLintResults([ - parseLegacyLintResult(JSON.stringify({ issues: [ERROR_ISSUE] }), "public.f1"), + parseLegacyLintResult(stringifyJson({ issues: [ERROR_ISSUE] }), "public.f1"), ]); expect(out.stdoutText).toBe(expected); expect(out.stderrText).toContain("Connecting to local database..."); @@ -265,7 +267,7 @@ describe("legacy db lint", () => { const exit = yield* Effect.exit(legacyDbLint(flags({ schema: ["public"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to enable pgsql_check"); + expect(stringifyJson(exit.cause)).toContain("failed to enable pgsql_check"); } }).pipe(Effect.provide(layer)); }); @@ -276,7 +278,7 @@ describe("legacy db lint", () => { const exit = yield* Effect.exit(legacyDbLint(flags({ schema: ["public"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to marshal json"); + expect(stringifyJson(exit.cause)).toContain("failed to marshal json"); } }).pipe(Effect.provide(layer)); }); @@ -287,7 +289,7 @@ describe("legacy db lint", () => { const exit = yield* Effect.exit(legacyDbLint(flags({ schema: ["public"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to query rows"); + expect(stringifyJson(exit.cause)).toContain("failed to query rows"); } }).pipe(Effect.provide(layer)); }); @@ -298,7 +300,7 @@ describe("legacy db lint", () => { const exit = yield* Effect.exit(legacyDbLint(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to list schemas"); + expect(stringifyJson(exit.cause)).toContain("failed to list schemas"); } }).pipe(Effect.provide(layer)); }); @@ -332,10 +334,11 @@ describe("legacy db lint", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure)).toBe(true); if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(LegacyDbLintFailOnError); - expect((failure.value as LegacyDbLintFailOnError).message).toBe( - "fail-on is set to warning, non-zero exit", - ); + if (failure.value instanceof LegacyDbLintFailOnError) { + expect(failure.value.message).toBe("fail-on is set to warning, non-zero exit"); + } else { + expect.fail("expected LegacyDbLintFailOnError"); + } } } // The result is still printed to stdout before the non-zero exit. @@ -351,7 +354,7 @@ describe("legacy db lint", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("fail-on is set to error, non-zero exit"); + expect(stringifyJson(exit.cause)).toContain("fail-on is set to error, non-zero exit"); } }).pipe(Effect.provide(layer)); }); @@ -390,7 +393,7 @@ describe("legacy db lint", () => { const exit = yield* Effect.exit(legacyDbLint(flags({ dbUrl: Option.some("postgres://x") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(stringifyJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be", ); } @@ -532,7 +535,7 @@ describe("legacy db lint", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(stringifyJson(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } @@ -591,7 +594,7 @@ describe("legacy db lint", () => { const exit = yield* Effect.exit(legacyDbLint(flags({ schema: ["public"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(stringifyJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); } diff --git a/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts b/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts index 94e01b7d4d..325b226169 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.layers.unit.test.ts @@ -56,6 +56,7 @@ import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.ser import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyIdentityStitch } from "../../../shared/legacy-identity-stitch.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyDbAdvisorsRuntimeLayer } from "../advisors/advisors.layers.ts"; import { legacyDbLintRuntimeLayer } from "./lint.layers.ts"; @@ -121,6 +122,7 @@ function ambientStubs() { mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, heavyServiceStubs, + makeLegacyViperEnvLayer(), ); } @@ -131,7 +133,7 @@ describe("legacyDbLintRuntimeLayer — LegacyIdentityStitch exposure", () => { return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(legacyDbLintRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyDbLintRuntimeLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); }); @@ -143,7 +145,9 @@ describe("legacyDbAdvisorsRuntimeLayer — LegacyIdentityStitch exposure (regres return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(legacyDbAdvisorsRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe( + Effect.provide(legacyDbAdvisorsRuntimeLayer.pipe(Layer.provideMerge(ambientStubs()))), + ); }, ); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts index c11ea3b491..bf074a094c 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.e2e.test.ts @@ -11,12 +11,11 @@ describe("supabase db pull (legacy)", () => { test( "--declarative with --diff-engine exits non-zero (mutually exclusive)", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode } = await runSupabase( - ["db", "pull", "--declarative", "--diff-engine", "migra"], - { entrypoint: "legacy" }, - ); - expect(exitCode).not.toBe(0); - }, + () => + runSupabase(["db", "pull", "--declarative", "--diff-engine", "migra"], { + entrypoint: "legacy", + }).then(({ exitCode }) => { + expect(exitCode).not.toBe(0); + }), ); }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index 813bc383ea..2d89dda733 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -218,7 +218,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Make an allowlisted `supabase/.env` registry override visible to the // synchronous `process.env` reader in `legacyGetRegistryImageUrl` (the pg_dump // seed + migra/pg-delta diff images), reverted when this scope closes. - yield* legacyApplyProjectEnv(projectEnv); + const effectiveProjectEnv = { + ...projectEnv, + ...(yield* legacyApplyProjectEnv(projectEnv)), + }; const name = Option.getOrElse(flags.name, () => "remote_schema"); // `--declarative` and the deprecated `--use-pg-delta` both bind to the same // `useDeclarative` outcome, so when BOTH are passed the LAST occurrence in @@ -251,22 +254,18 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy if (Option.isSome(flags.linked)) targetSet.push("linked"); if (Option.isSome(flags.local)) targetSet.push("local"); if (targetSet.length > 1) { - return yield* Effect.fail( - new LegacyDbPullTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbPullTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${[...targetSet].sort().join(" ")}] were all set`, + }); } for (const [flagName, present] of [ ["declarative", Option.isSome(flags.declarative)], ["use-pg-delta", Option.isSome(flags.usePgDelta)], ] as const) { if (present && Option.isSome(flags.diffEngine)) { - return yield* Effect.fail( - new LegacyDbPullEngineConflictError({ - message: `if any flags in the group [${flagName} diff-engine] are set none of the others can be; [${[flagName, "diff-engine"].sort().join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbPullEngineConflictError({ + message: `if any flags in the group [${flagName} diff-engine] are set none of the others can be; [${[flagName, "diff-engine"].sort().join(" ")}] were all set`, + }); } } @@ -280,12 +279,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // discarded on a non-linked target — see push.handler.ts's identical guard // for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyDbPullTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbPullTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // `--experimental`'s structured-dump mode delegates the whole pull to the @@ -302,12 +299,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // avoids (see `LegacyProjectRefResolver`'s use below). Mirrors // `diff.handler.ts`'s identical `--use-pg-schema` guard. if (Option.isSome(flags.projectRef) && delegatesExperimentalPull) { - return yield* Effect.fail( - new LegacyDbPullTargetFlagsError({ - message: - "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", - }), - ); + return yield* new LegacyDbPullTargetFlagsError({ + message: + "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", + }); } // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN @@ -382,7 +377,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy cwd: cliConfig.workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, + projectEnv: { ...toml.projectEnv, ...effectiveProjectEnv }, }; const formatOptions = Option.getOrElse(toml.pgDelta.formatOptions, () => ""); @@ -515,7 +510,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ...(connType === "linked" && linkedRef !== undefined ? { projectRef: linkedRef } : {}), - debug: legacyIsPgDeltaDebugEnabled(), + debug: legacyIsPgDeltaDebugEnabled({ ...toml.projectEnv, ...effectiveProjectEnv }), strictCoverage: flags.strictCoverage, noCache: false, }); @@ -629,13 +624,11 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); const sync = legacyReconcileMigrations(remote, local, connType === "local"); if (sync.kind === "conflict") { - return yield* Effect.fail( - new LegacyDbPullMigrationConflictError({ - message: - "The remote database's migration history does not match local files in supabase/migrations directory.", - suggestion: sync.suggestion, - }), - ); + return yield* new LegacyDbPullMigrationConflictError({ + message: + "The remote database's migration history does not match local files in supabase/migrations directory.", + suggestion: sync.suggestion, + }); } // Initial pull, migra engine: seed the migration file with a pg_dump of the // remote schema, then run the migra diff below as a second pass appended to @@ -689,8 +682,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy seedWroteBytes = false; return fs .writeFile(migrationPath, new Uint8Array(0), { mode: MIGRATION_FILE_MODE }) - .pipe(Effect.mapError(toDumpOpenError)) .pipe( + Effect.mapError(toDumpOpenError), Effect.andThen( Effect.scoped( Effect.gen(function* () { @@ -701,7 +694,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy image, script: legacyDumpSchemaScript, env: legacyBuildSchemaDumpEnv(target, dumpEnvOpt), - projectEnvValues: projectEnv, + projectEnvValues: effectiveProjectEnv, onStdout: (chunk) => { if (chunk.length > 0) seedWroteBytes = true; return file.writeAll(chunk).pipe( @@ -744,14 +737,12 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy reprintOnRetry: Effect.void, }); if (dumpResult.exitCode !== 0) { - return yield* Effect.fail( - new LegacyDbPullDumpError({ - message: `error running container: exit ${dumpResult.exitCode}`, - ...(legacyIsIPv6ConnectivityError(dumpResult.stderr) - ? { suggestion: legacyIpv6Suggestion() } - : {}), - }), - ); + return yield* new LegacyDbPullDumpError({ + message: `error running container: exit ${dumpResult.exitCode}`, + ...(legacyIsIPv6ConnectivityError(dumpResult.stderr) + ? { suggestion: legacyIpv6Suggestion() } + : {}), + }); } } @@ -823,7 +814,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }, schema: diffSchema, formatOptions, - debug: legacyIsPgDeltaDebugEnabled(), + debug: legacyIsPgDeltaDebugEnabled({ + ...toml.projectEnv, + ...effectiveProjectEnv, + }), strictCoverage: flags.strictCoverage, }); } @@ -872,12 +866,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ), ); if (debugDir !== undefined) { - return yield* Effect.fail( - new LegacyDbPullInSyncError({ - message: `No schema changes found (debug bundle: ${debugDir})`, - suggestion: IN_SYNC_SUGGESTION, - }), - ); + return yield* new LegacyDbPullInSyncError({ + message: `No schema changes found (debug bundle: ${debugDir})`, + suggestion: IN_SYNC_SUGGESTION, + }); } } if ( @@ -885,19 +877,15 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy diffOutcome.debug?.directory !== undefined ) { yield* output.raw(legacyDebugBundleMessage(diffOutcome.debug.directory), "stderr"); - return yield* Effect.fail( - new LegacyDbPullInSyncError({ - message: `No schema changes found (debug bundle: ${diffOutcome.debug.directory})`, - suggestion: IN_SYNC_SUGGESTION, - }), - ); - } - return yield* Effect.fail( - new LegacyDbPullInSyncError({ - message: "No schema changes found", + return yield* new LegacyDbPullInSyncError({ + message: `No schema changes found (debug bundle: ${diffOutcome.debug.directory})`, suggestion: IN_SYNC_SUGGESTION, - }), - ); + }); + } + return yield* new LegacyDbPullInSyncError({ + message: "No schema changes found", + suggestion: IN_SYNC_SUGGESTION, + }); } // Build the list of migration files to record in the remote history. The @@ -973,12 +961,10 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // A dump that produced nothing followed by an empty diff leaves the file // empty → in sync. if (seededFromDump && !seedWroteBytes && diffEmpty) { - return yield* Effect.fail( - new LegacyDbPullInSyncError({ - message: "No schema changes found", - suggestion: IN_SYNC_SUGGESTION, - }), - ); + return yield* new LegacyDbPullInSyncError({ + message: "No schema changes found", + suggestion: IN_SYNC_SUGGESTION, + }); } writtenMigrations.push({ path: migrationPath, version: timestamp }); } diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index bb940110f7..6b8fd010d4 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -1,10 +1,19 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Path, + Schema, +} from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import type * as PlatformError from "effect/PlatformError"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { @@ -30,6 +39,7 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; import { @@ -65,7 +75,67 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); -const EXPORT_JSON = JSON.stringify({ +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const PgDeltaDiffFileSchema = Schema.Struct({ + name: Schema.String, + sql: Schema.String, + transactionMode: Schema.Union([Schema.Literal("transactional"), Schema.Literal("none")]), +}); +const PgDeltaDiffEnvelopeSchema = Schema.Struct({ + files: Schema.Array(PgDeltaDiffFileSchema), +}); +const decodePgDeltaDiffEnvelope = Schema.decodeUnknownSync( + Schema.fromJsonString(PgDeltaDiffEnvelopeSchema), +); + +const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const join = (...segments: ReadonlyArray<string>): string => pathService.join(...segments); +const basename = (value: string): string => pathService.basename(value); + +const makeDirectory = ( + directory: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(directory, { recursive: true }); + }); + +const writeText = ( + file: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(file, contents); + }); + +const readText = ( + file: string, +): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file); + }); + +const pathExists = ( + file: string, +): Effect.Effect<boolean, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(file); + }); + +const readDirectory = ( + directory: string, +): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(directory); + }); + +const EXPORT_JSON = encodeJson({ version: 1, mode: "declarative", files: [{ path: "schemas/public/t.sql", order: 0, statements: 1, sql: "create table t ();" }], @@ -76,7 +146,7 @@ const EXPORT_JSON = JSON.stringify({ const pgDeltaDiffEnvelope = ( units: ReadonlyArray<{ name: string; sql: string; transactionMode?: string }>, ): string => - JSON.stringify({ + encodeJson({ version: 1, files: units.map((unit, index) => ({ order: index + 1, @@ -131,9 +201,18 @@ interface SetupOpts { // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, // instead of silently falling back to `opts.resolvedRef ?? LEGACY_VALID_REF`. readonly linkedFails?: boolean; + readonly pgDeltaDebug?: boolean; + readonly env?: Readonly<Record<string, string>>; + readonly fixtures?: ReadonlyArray< + Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> + >; } function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.promptConfirmResponses, @@ -180,7 +259,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { changes: false, sql: "", files: [], - ...(process.env["PGDELTA_DEBUG"] !== undefined + ...(opts.pgDeltaDebug === true ? { debug: opts.engineImplementation === "next" @@ -196,28 +275,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); } try { - const parsed: unknown = JSON.parse(stdout); - if (typeof parsed !== "object" || parsed === null) throw new Error("invalid envelope"); - const rawFiles = Reflect.get(parsed, "files"); - if (!Array.isArray(rawFiles)) throw new Error("invalid envelope"); - const files = rawFiles.map((raw, index) => { - if (typeof raw !== "object" || raw === null) throw new Error("invalid file"); - const sql = Reflect.get(raw, "sql"); - const name = Reflect.get(raw, "name"); - const transactionMode = Reflect.get(raw, "transactionMode"); - if (typeof sql !== "string" || typeof name !== "string") { - throw new Error("invalid file"); - } - if (transactionMode !== "transactional" && transactionMode !== "none") { - throw new Error(`unknown transaction mode ${String(transactionMode)}`); - } - return { - sequence: index + 1, - name, - sql, - transactionMode, - }; - }); + const parsed = decodePgDeltaDiffEnvelope(stdout); + const files = parsed.files.map((file, index) => ({ + sequence: index + 1, + name: file.name, + sql: file.sql, + transactionMode: file.transactionMode, + })); return Effect.succeed({ changes: files.length > 0, sql: files.map((file) => file.sql).join("\n"), @@ -366,24 +430,29 @@ function setup(workdir: string, opts: SetupOpts = {}) { const poolerFallbackCalls: unknown[] = []; const resolveCalls: unknown[] = []; + const fixtureEffect = Effect.forEach(opts.fixtures ?? [], (fixture) => fixture, { + concurrency: 1, + }).pipe(Effect.asVoid, Effect.provide(BunServices.layer)); + const fixtureLayer = Layer.effectDiscard(fixtureEffect); const resolver = Layer.succeed(LegacyDbConfigResolver, { - resolve: (resolveFlags) => { - resolveCalls.push(resolveFlags); - const { connType } = resolveFlags; - return Effect.succeed({ - conn: { - // A direct `db.<ref>.<projectHost>` host so the pooler-fallback gate - // matches on the linked path. - host: connType === "local" ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co", - port: 5432, - user: "postgres", - password: "x", - database: "postgres", - }, - isLocal: connType === "local", - ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), - }); - }, + resolve: (resolveFlags) => + Effect.sync(() => { + resolveCalls.push(resolveFlags); + const { connType } = resolveFlags; + return { + conn: { + // A direct `db.<ref>.<projectHost>` host so the pooler-fallback gate + // matches on the linked path. + host: connType === "local" ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + user: "postgres", + password: "x", + database: "postgres", + }, + isLocal: connType === "local", + ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), + }; + }), resolvePoolerFallback: (resolveFlags) => { poolerFallbackCalls.push(resolveFlags); return Effect.succeed( @@ -443,6 +512,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { // real implementation — `Layer.mergeAll` is last-wins on a shared service, // matching `start.integration.test.ts`'s own established ordering. BunServices.layer, + fixtureLayer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, telemetry.layer, cache.layer, @@ -475,6 +547,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); return { layer: baseLayer, + configProvider, out, proxyCalls, proxyCaptureCalls, @@ -516,17 +589,20 @@ const streamText = (out: ReturnType<typeof mockOutput>, stream: "stdout" | "stde .join(""), ); -const seedMigration = (workdir: string, version: string) => { +const seedMigration = ( + workdir: string, + version: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => { const dir = join(workdir, "supabase", "migrations"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, `${version}_local.sql`), "create table local ();\n"); + return makeDirectory(dir).pipe( + Effect.andThen(writeText(join(dir, `${version}_local.sql`), "create table local ();\n")), + ); }; const tmp = useLegacyTempWorkdir(); describe("legacy db pull", () => { it.effect("pulls a migration (pgdelta engine) and updates remote history under --yes", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([ @@ -536,17 +612,16 @@ describe("legacy db pull", () => { }, ]), yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta"), strictCoverage: true })); const dir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); + expect(yield* pathExists(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); // A single-unit plan keeps the unchanged `<ts>_remote_schema.sql` filename. - const written = readdirSync(dir).filter((f) => f.endsWith("_remote_schema.sql")); + const written = (yield* readDirectory(dir)).filter((f) => f.endsWith("_remote_schema.sql")); expect(written).toHaveLength(1); - expect(readFileSync(join(dir, written[0] ?? ""), "utf8")).toContain( - "create table remote ();", - ); + expect(yield* readText(join(dir, written[0] ?? ""))).toContain("create table remote ();"); // Prints the workdir-relative path, never the absolute one. expect(streamText(s.out, "stderr")).toContain( `Schema written to ${join("supabase", "migrations", written[0] ?? "")}\n`, @@ -570,13 +645,13 @@ describe("legacy db pull", () => { // The fake resolver fails as "unlinked" (`LegacyProjectNotLinkedError`) // absent the flag — only the flag can resolve a ref here. const FLAG_REF = "flagflagflagflagflag"; - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, projectId: Option.none(), linkedFails: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull( @@ -589,7 +664,6 @@ describe("legacy db pull", () => { it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), @@ -597,6 +671,7 @@ describe("legacy db pull", () => { // The workdir already resolves to LEGACY_VALID_REF (e.g. via // .temp/project-ref) — the flag must win over it. resolvedRef: "abcdefghijklmnopqrst", + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull( @@ -616,9 +691,11 @@ describe("legacy db pull", () => { flags({ local: Option.some(true), projectRef: Option.some(FLAG_REF) }), ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - ); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain( + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + ); + } // The guard fires before any connection resolution or cache write. expect(s.resolveCalls).toEqual([]); expect(s.cache.cached).toBe(false); @@ -636,9 +713,11 @@ describe("legacy db pull", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( - "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", - ); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain( + "--project-ref is not supported with the --experimental structured-dump pull; use --declarative instead", + ); + } expect(s.proxyCalls).toEqual([]); expect(s.proxyCaptureCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); @@ -651,7 +730,6 @@ describe("legacy db pull", () => { // VALUE then a statement using the new value) come back as several units; each // is written to its own migration file with a strictly increasing timestamp and // recorded in the remote history. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([ @@ -664,11 +742,12 @@ describe("legacy db pull", () => { }, ]), yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); const dir = join(tmp.current, "supabase", "migrations"); - const written = readdirSync(dir) + const written = (yield* readDirectory(dir)) .filter((f) => f !== "20240101000000_local.sql") .sort(); expect(written).toHaveLength(3); @@ -679,7 +758,7 @@ describe("legacy db pull", () => { const versions = written.map((f) => f.slice(0, 14)); expect((versions[0] ?? "") < (versions[1] ?? "")).toBe(true); expect((versions[1] ?? "") < (versions[2] ?? "")).toBe(true); - const nonTransactional = readFileSync(join(dir, written[2] ?? ""), "utf8"); + const nonTransactional = yield* readText(join(dir, written[2] ?? "")); expect(nonTransactional.startsWith("-- pg-delta: transaction=false\n")).toBe(true); expect(nonTransactional).toContain("create index concurrently i on t (c);"); // One "Schema written to" line per unit, each printing the workdir-relative @@ -703,7 +782,6 @@ describe("legacy db pull", () => { () => { // The structured payload must list ALL written migration files in write order, // not just the first (`schemaWritten`). A pg-delta plan writes one file per unit. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { format: "json", remoteVersions: ["20240101000000"], @@ -716,6 +794,7 @@ describe("legacy db pull", () => { sql: "-- unit 3\n\ncreate index concurrently i on t (c);", }, ]), + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); @@ -735,11 +814,11 @@ describe("legacy db pull", () => { ); it.effect("a malformed pg-delta diff envelope surfaces a parse error, not 'in sync'", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "not a valid envelope{", yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( @@ -755,23 +834,19 @@ describe("legacy db pull", () => { // `contrib_regression` target for a local database, so schema_paths does still // shape their output and the warning would be factually wrong. it.effect("pulls with the next engine and warns that schema_paths no longer applies", () => { - seedMigration(tmp.current, "20240101000000"); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db.migrations]", - 'schema_paths = ["database/*.sql"]', - "", - "[experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); + const configToml = [ + "[db.migrations]", + 'schema_paths = ["database/*.sql"]', + "", + "[experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], engineImplementation: "next", // The next engine's mock parses `edgeStdout` as a rendered-file envelope. - edgeStdout: JSON.stringify({ + edgeStdout: encodeJson({ files: [ { name: "schema_changes", @@ -781,6 +856,11 @@ describe("legacy db pull", () => { ], }), yes: true, + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", "config.toml"), configToml), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -791,15 +871,16 @@ describe("legacy db pull", () => { }); it.effect("pulls with migra and does not warn about schema_paths", () => { - seedMigration(tmp.current, "20240101000000"); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[db.migrations]", 'schema_paths = ["database/*.sql"]', ""].join("\n"), - ); + const configToml = ["[db.migrations]", 'schema_paths = ["database/*.sql"]', ""].join("\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", "config.toml"), configToml), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -815,7 +896,7 @@ describe("legacy db pull", () => { err.indexOf("Creating shadow database..."), ); const dir = join(tmp.current, "supabase", "migrations"); - const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + const file = (yield* readDirectory(dir)).find((f) => f.endsWith("_remote_schema.sql")); expect(err).toContain(`Schema written to ${join("supabase", "migrations", file ?? "")}\n`); expect(err).not.toContain(tmp.current); }).pipe(Effect.provide(s.layer)); @@ -831,20 +912,23 @@ describe("legacy db pull", () => { // `resolver.resolve()` or the connectivity check ever run — so `resolveCalls` must // stay empty here, proving the shadow's config validation ran first, not just that // the command failed. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[api]", - "enabled = true", - "[api.tls]", - "enabled = true", - 'cert_path = "missing-cert.pem"', - 'key_path = "missing-key.pem"', - "", - ].join("\n"), - ); - const s = setup(tmp.current, { remoteVersions: [], edgeStdout: "" }); + const configToml = [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"); + const s = setup(tmp.current, { + remoteVersions: [], + edgeStdout: "", + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", "config.toml"), configToml), + ], + }); return Effect.gen(function* () { const error = yield* legacyDbPull(flags()).pipe(Effect.flip); expect(error.message).toContain("failed to read TLS cert"); @@ -872,10 +956,12 @@ describe("legacy db pull", () => { // (established output contract). expect(err).toContain(`Declarative schema written to ${join("supabase", "schemas")}\n`); expect(err).not.toContain(tmp.current); - expect(existsSync(join(tmp.current, "supabase", "schemas", "public", "t.sql"))).toBe(true); + expect(yield* pathExists(join(tmp.current, "supabase", "schemas", "public", "t.sql"))).toBe( + true, + ); expect( - JSON.parse( - readFileSync(join(tmp.current, "supabase", "schemas", ".pgdelta-export.json"), "utf8"), + decodeJson( + yield* readText(join(tmp.current, "supabase", "schemas", ".pgdelta-export.json")), ), ).toMatchObject({ formatVersion: 1, @@ -907,12 +993,16 @@ describe("legacy db pull", () => { // Points schema_paths at the declarative dir when pg-delta is disabled in // config (db pull does not force-enable it), so later db reset/db diff read // the pulled files. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\n"); - const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + const s = setup(tmp.current, { + edgeStdout: EXPORT_JSON, + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", "config.toml"), "[db]\n"), + ], + }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); - const config = readFileSync(join(tmp.current, "supabase", "config.toml"), "utf8"); + const config = yield* readText(join(tmp.current, "supabase", "config.toml")); expect(config).toContain("[db.migrations]"); expect(config).toContain('schema_paths = [\n "schemas",\n]'); }).pipe(Effect.provide(s.layer)); @@ -922,13 +1012,17 @@ describe("legacy db pull", () => { it.effect("pull --declarative leaves schema_paths untouched when pg-delta is enabled", () => { // For an enabled config the declarative dir is already the source of truth, so // the schema_paths rewrite is skipped (the gate reads the config value). - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); const original = "[experimental.pgdelta]\nenabled = true\n"; - writeFileSync(join(tmp.current, "supabase", "config.toml"), original); - const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + const s = setup(tmp.current, { + edgeStdout: EXPORT_JSON, + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", "config.toml"), original), + ], + }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); - const config = readFileSync(join(tmp.current, "supabase", "config.toml"), "utf8"); + const config = yield* readText(join(tmp.current, "supabase", "config.toml")); expect(config).toBe(original); }).pipe(Effect.provide(s.layer)); }); @@ -936,15 +1030,19 @@ describe("legacy db pull", () => { it.effect("pull --declarative replaces an existing schema_paths block in place", () => { // A regex replace-or-append rewrites a present schema_paths block rather than // appending a duplicate. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - '[db.migrations]\nschema_paths = [\n "schemas/*.sql",\n]\n', - ); - const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); + const s = setup(tmp.current, { + edgeStdout: EXPORT_JSON, + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText( + join(tmp.current, "supabase", "config.toml"), + '[db.migrations]\nschema_paths = [\n "schemas/*.sql",\n]\n', + ), + ], + }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true) })); - const config = readFileSync(join(tmp.current, "supabase", "config.toml"), "utf8"); + const config = yield* readText(join(tmp.current, "supabase", "config.toml")); expect(config).toContain('schema_paths = [\n "schemas",\n]'); expect(config).not.toContain("schemas/*.sql"); }).pipe(Effect.provide(s.layer)); @@ -990,17 +1088,19 @@ describe("legacy db pull", () => { // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be // suppressed here too, or it silently wins back over the already-gated `toml.projectId` // (mirrors `diff.integration.test.ts`'s identically-named test). - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), - ); const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, resolvedRef: "abcdefghijklmnopqrst", // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) // project — must NOT win over the matched remote's own `project_id`. projectId: Option.some("unrelated-env-project"), + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText( + join(tmp.current, "supabase", "config.toml"), + ["[remotes.staging]", 'project_id = "abcdefghijklmnopqrst"', ""].join("\n"), + ), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ declarative: Option.some(true), linked: Option.some(true) })); @@ -1015,12 +1115,12 @@ describe("legacy db pull", () => { // Both flags bind to one variable, so the last occurrence wins: this // invocation ends false => migration mode + history repair, NOT declarative // export. OR-ing the two parsed flags would wrongly take the declarative path. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, args: ["db", "pull", "--declarative", "--use-pg-delta=false"], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull( @@ -1034,12 +1134,12 @@ describe("legacy db pull", () => { it.effect( "--use-pg-delta --declarative=false stays in migration mode (Go last-occurrence-wins)", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, args: ["db", "pull", "--use-pg-delta", "--declarative=false"], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull( @@ -1060,14 +1160,18 @@ describe("legacy db pull", () => { expect(s.engineCalls[0]?.operation).toBe("export"); // Reaching the declarative write (rather than a migration file / history // upsert) proves the declarative export path ran. - expect(existsSync(join(tmp.current, "supabase", "schemas", "public", "t.sql"))).toBe(true); + expect(yield* pathExists(join(tmp.current, "supabase", "schemas", "public", "t.sql"))).toBe( + true, + ); expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }); it.effect("a migration-history conflict fails with the repair suggestion", () => { - seedMigration(tmp.current, "20240102000000"); - const s = setup(tmp.current, { remoteVersions: ["20240101000000"] }); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + fixtures: [seedMigration(tmp.current, "20240102000000")], + }); return Effect.gen(function* () { const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -1096,9 +1200,9 @@ describe("legacy db pull", () => { expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); // The migration file holds the dump output followed by the appended diff. const dir = join(tmp.current, "supabase", "migrations"); - const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + const file = (yield* readDirectory(dir)).find((f) => f.endsWith("_remote_schema.sql")); expect(file).toBeDefined(); - const content = readFileSync(join(dir, file ?? ""), "utf8"); + const content = yield* readText(join(dir, file ?? "")); expect(content).toContain("create table dumped ();"); expect(content).toContain("create table diffed ();"); expect(content.indexOf("dumped")).toBeLessThan(content.indexOf("diffed")); @@ -1160,9 +1264,9 @@ describe("legacy db pull", () => { const exit = yield* legacyDbPull(flags()).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const dir = join(tmp.current, "supabase", "migrations"); - const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + const file = (yield* readDirectory(dir)).find((f) => f.endsWith("_remote_schema.sql")); expect(file).toBeDefined(); - expect(readFileSync(join(dir, file ?? ""), "utf8")).toContain("create table dumped ();"); + expect(yield* readText(join(dir, file ?? ""))).toContain("create table dumped ();"); expect(streamText(s.out, "stderr")).toContain( `Schema written to ${join("supabase", "migrations", file ?? "")}\n`, ); @@ -1281,8 +1385,11 @@ describe("legacy db pull", () => { }); it.effect("an in-sync pull (empty diff) fails with 'No schema changes found'", () => { - seedMigration(tmp.current, "20240101000000"); - const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "" }); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "", + fixtures: [seedMigration(tmp.current, "20240101000000")], + }); return Effect.gen(function* () { // Go's message and non-zero exit are the contract; the generic // "rerun with --debug" footer is replaced by an explanation instead @@ -1302,38 +1409,32 @@ describe("legacy db pull", () => { () => { // A debug bundle is saved and its path embedded in the in-sync error when // PGDELTA_DEBUG is set on an empty pg-delta diff. - seedMigration(tmp.current, "20240101000000"); - const catalog = JSON.stringify({ tables: [{ schema: "public", name: "t" }] }); + const catalog = encodeJson({ tables: [{ schema: "public", name: "t" }] }); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "", // empty diff catalogStdout: catalog, // shadow + remote catalog exports succeed yes: true, + pgDeltaDebug: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { - const prev = process.env["PGDELTA_DEBUG"]; - process.env["PGDELTA_DEBUG"] = "1"; - try { - const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( - Effect.flip, - ); - expect(error.message).toContain("No schema changes found (debug bundle:"); - } finally { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - } + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.flip, + ); + expect(error.message).toContain("No schema changes found (debug bundle:"); const debugRoot = join(tmp.current, "supabase", ".temp", "pgdelta", "debug"); - const ids = existsSync(debugRoot) ? readdirSync(debugRoot) : []; + const ids = (yield* pathExists(debugRoot)) ? yield* readDirectory(debugRoot) : []; expect(ids).toHaveLength(1); const bundleDir = join(debugRoot, ids[0] ?? ""); - const files = readdirSync(bundleDir); + const files = yield* readDirectory(bundleDir); expect(files).toContain("source-catalog.json"); expect(files).toContain("target-catalog.json"); expect(files).toContain("connection.txt"); expect(files).toContain("error.txt"); - expect(readFileSync(join(bundleDir, "error.txt"), "utf8")).toBe("No schema changes found"); + expect(yield* readText(join(bundleDir, "error.txt"))).toBe("No schema changes found"); // connection.txt is password-redacted (→ xxxxx). - expect(readFileSync(join(bundleDir, "connection.txt"), "utf8")).toContain( + expect(yield* readText(join(bundleDir, "connection.txt"))).toContain( "url=postgresql://postgres:xxxxx@", ); expect(streamText(s.out, "stderr")).toContain("pg-delta returned 0 statements."); @@ -1343,20 +1444,23 @@ describe("legacy db pull", () => { ); it.effect("an empty pg-delta diff without PGDELTA_DEBUG writes no debug bundle", () => { - seedMigration(tmp.current, "20240101000000"); - const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "", yes: true }); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + edgeStdout: "", + yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], + }); return Effect.gen(function* () { const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( Effect.flip, ); expect(error.message).toBe("No schema changes found"); const debugRoot = join(tmp.current, "supabase", ".temp", "pgdelta", "debug"); - expect(existsSync(debugRoot) ? readdirSync(debugRoot) : []).toEqual([]); + expect((yield* pathExists(debugRoot)) ? yield* readDirectory(debugRoot) : []).toEqual([]); }).pipe(Effect.provide(s.layer)); }); it.effect("reports the next-generation debug directory for an empty pg-delta diff", () => { - seedMigration(tmp.current, "20240101000000"); const debugDir = join( tmp.current, "supabase", @@ -1371,31 +1475,26 @@ describe("legacy db pull", () => { edgeStdout: "", engineImplementation: "next", nextDebugDirectory: debugDir, + pgDeltaDebug: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { - const previous = process.env["PGDELTA_DEBUG"]; - process.env["PGDELTA_DEBUG"] = "1"; - try { - const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( - Effect.flip, - ); - expect(error.message).toBe(`No schema changes found (debug bundle: ${debugDir})`); - expect(streamText(s.out, "stderr")).toContain(`Debug information saved to`); - expect(streamText(s.out, "stderr")).toContain(debugDir); - } finally { - if (previous === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = previous; - } + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.flip, + ); + expect(error.message).toBe(`No schema changes found (debug bundle: ${debugDir})`); + expect(streamText(s.out, "stderr")).toContain(`Debug information saved to`); + expect(streamText(s.out, "stderr")).toContain(debugDir); }).pipe(Effect.provide(s.layer)); }); it.effect("prompts to update history and inserts on yes (tty)", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: true, promptConfirmResponses: [true], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1404,12 +1503,12 @@ describe("legacy db pull", () => { }); it.effect("declining the history prompt does not insert (tty)", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: true, promptConfirmResponses: [false], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1423,11 +1522,11 @@ describe("legacy db pull", () => { // proceeds to update the remote history. (The production clack prompt would // hang on a non-TTY — that no-hang behavior is proven end-to-end in // `pull.live.test.ts`; here the empty piped scan defaults.) - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: false, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1439,12 +1538,12 @@ describe("legacy db pull", () => { // Regression: piped stdin is scanned before defaulting, so a piped `n` cancels // the history update even on a non-terminal — `schema_migrations` must not be // touched against the user's explicit decline. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: false, pipedAnswers: ["n"], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1457,12 +1556,12 @@ describe("legacy db pull", () => { }); it.effect("emits a json envelope and suppresses 'Finished' in machine mode", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { format: "json", remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1476,11 +1575,11 @@ describe("legacy db pull", () => { }); it.effect("auto-accepts the history update in non-tty mode without --yes", () => { - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: false, + fixtures: [seedMigration(tmp.current, "20240101000000")], // no --yes: a non-interactive prompt falls back to the default (true). }); return Effect.gen(function* () { @@ -1493,14 +1592,13 @@ describe("legacy db pull", () => { // `SUPABASE_YES` auto-confirms even on a TTY with no piped answer. The native // path resolves `yes` via `legacyResolveYesWithProjectEnv`, not the raw `--yes` // flag, so the shell env var is honored here too. - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", + env: { SUPABASE_YES: "1" }, // A TTY with no scripted prompt response: only SUPABASE_YES makes this pass. stdinIsTty: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1508,15 +1606,7 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain( "Update remote migration history table? [Y/n] y", ); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }); it.effect("honors SUPABASE_YES from supabase/.env for the initial-pull history update", () => { @@ -1524,30 +1614,24 @@ describe("legacy db pull", () => { // only in `supabase/.env` auto-confirms — with no shell env or `--yes`. The // native path resolves via `legacyResolveYesWithProjectEnv`, reading the loaded // project env map. - const prev = process.env["SUPABASE_YES"]; - delete process.env["SUPABASE_YES"]; // only the project .env value must apply - seedMigration(tmp.current, "20240101000000"); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); - const s = setup(tmp.current, { + const workdir = tmp.current; + const s = setup(workdir, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", // Pipe `n` on a non-TTY: only honoring the .env SUPABASE_YES (which is read // before stdin, so it wins over the piped decline) still updates history. stdinIsTty: false, pipedAnswers: ["n"], + fixtures: [ + seedMigration(workdir, "20240101000000"), + makeDirectory(join(workdir, "supabase")), + writeText(join(workdir, "supabase", ".env"), "SUPABASE_YES=true\n"), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.historyUpserts.length).toBe(1); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }); it.effect( @@ -1557,33 +1641,25 @@ describe("legacy db pull", () => { // registry mirror set only in `supabase/.env` is used for the native pg_dump // seed. The handler applies it with `legacyApplyProjectEnv` (scoped to the run, // reverted on close); the loader itself stays pure. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", ".env"), - "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", - ); const s = setup(tmp.current, { remoteVersions: [], // no remote history → initial-migra pg_dump path dumpStdout: "create table dumped ();\n", edgeStdout: "", yes: true, + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText( + join(tmp.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\n", + ), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); // The pg_dump container image is rewritten to the configured mirror. expect(s.dumpCalls[0]?.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); @@ -1593,29 +1669,21 @@ describe("legacy db pull", () => { // Host networking is the default, but a resolved `--network-id`/`SUPABASE_NETWORK_ID` // value overrides it whenever non-empty — a value sourced only from `supabase/.env` // still wins over host. - const prev = process.env["SUPABASE_NETWORK_ID"]; - delete process.env["SUPABASE_NETWORK_ID"]; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); const s = setup(tmp.current, { remoteVersions: [], // no remote history → initial-migra pg_dump path dumpStdout: "create table dumped ();\n", edgeStdout: "", yes: true, + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.dumpCalls.length).toBeGreaterThanOrEqual(1); expect(s.dumpCalls[0]?.network).toEqual({ _tag: "named", name: "dotenv-net" }); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; - else process.env["SUPABASE_NETWORK_ID"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); @@ -1624,28 +1692,19 @@ describe("legacy db pull", () => { // SUPABASE_YES=1 supabase --yes=false db pull` must let the piped `n` decline // the history update rather than auto-confirming — schema_migrations stays // untouched. - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: false, pipedAnswers: ["n"], + env: { SUPABASE_YES: "1" }, args: ["db", "pull", "--yes=false"], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.historyUpserts.length).toBe(0); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }); it.effect( @@ -1656,15 +1715,14 @@ describe("legacy db pull", () => { // string "--yes=false" — `--yes` was never actually set — so SUPABASE_YES=1 // must still auto-confirm the history update rather than a scanner wrongly // reading an explicit `--yes=false` here. - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", // A TTY with no scripted prompt response: only SUPABASE_YES makes this pass. stdinIsTty: true, + env: { SUPABASE_YES: "1" }, args: ["db", "pull", "--password", "--yes=false"], + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1672,31 +1730,16 @@ describe("legacy db pull", () => { expect(streamText(s.out, "stderr")).toContain( "Update remote migration history table? [Y/n] y", ); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); it.effect( "SUPABASE_EXPERIMENTAL prints a deprecation warning and delegates the structured-dump pull to Go", () => { - const s = setup(tmp.current); + const s = setup(tmp.current, { env: { SUPABASE_EXPERIMENTAL: "true" } }); return Effect.gen(function* () { - const prev = process.env["SUPABASE_EXPERIMENTAL"]; - process.env["SUPABASE_EXPERIMENTAL"] = "true"; - try { - yield* legacyDbPull(flags()); - } finally { - if (prev === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = prev; - } + yield* legacyDbPull(flags()); expect(s.proxyCalls).toHaveLength(1); expect(s.proxyCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); // The Go child's own `ConnectByConfig` prints the Connecting line; the @@ -1833,27 +1876,18 @@ describe("legacy db pull", () => { // `--experimental=false` must NOT be overridden by a truthy // `SUPABASE_EXPERIMENTAL` — the pull proceeds as normal instead of hitting the // retirement error. - const prev = process.env["SUPABASE_EXPERIMENTAL"]; - process.env["SUPABASE_EXPERIMENTAL"] = "true"; - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, args: ["db", "pull", "--experimental=false"], + env: { SUPABASE_EXPERIMENTAL: "true" }, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(streamText(s.out, "stderr")).toContain("Connecting to remote database...\n"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); @@ -1873,23 +1907,14 @@ describe("legacy db pull", () => { // positional with no `--` terminator of its own, a separate, pre-existing, // unfixed gap: a name that looks like a flag could be re-parsed as one by the // Go child). - const prev = process.env["SUPABASE_EXPERIMENTAL"]; - process.env["SUPABASE_EXPERIMENTAL"] = "true"; const s = setup(tmp.current, { args: ["db", "pull", "--", "--experimental=false"], + env: { SUPABASE_EXPERIMENTAL: "true" }, }); return Effect.gen(function* () { yield* legacyDbPull(flags({ name: Option.some("--experimental=false") })); expect(s.proxyCalls).toHaveLength(1); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); @@ -1920,36 +1945,29 @@ describe("legacy db pull", () => { // that examines every pre-terminator token without skipping consumed values would // wrongly read an explicit `--experimental=false` here and let the pull proceed // normally instead of falling back to SUPABASE_EXPERIMENTAL=true. - const prev = process.env["SUPABASE_EXPERIMENTAL"]; - process.env["SUPABASE_EXPERIMENTAL"] = "true"; const s = setup(tmp.current, { args: ["db", "pull", "--password", "--experimental=false"], + env: { SUPABASE_EXPERIMENTAL: "true" }, }); return Effect.gen(function* () { yield* legacyDbPull(flags()); expect(s.proxyCalls).toHaveLength(1); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); it.effect("a project supabase/.env enabling pg-delta selects the pg-delta engine", () => { // A project .env must select pg-delta even when the shell env doesn't set it. // The handler reads it via toml.envLookup, not process.env. - seedMigration(tmp.current, "20240101000000"); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase")), + writeText(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL_PG_DELTA=true\n"), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -1958,14 +1976,16 @@ describe("legacy db pull", () => { }); it.effect("db pull --local with pg-delta-next diffs against the live local database", () => { - seedMigration(tmp.current, "20240101000000"); - mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { engineImplementation: "next", remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase", "schemas")), + writeText(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ local: Option.some(true), diffEngine: Option.some("pg-delta") })); @@ -1980,13 +2000,15 @@ describe("legacy db pull", () => { // real declarative schema file makes the native `loadDeclaredSchemas` branch // non-empty, so `legacyPrepareShadowSource` redirects the diff target to the // shadow's own `contrib_regression` override database. - seedMigration(tmp.current, "20240101000000"); - mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase", "schemas")), + writeText(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ local: Option.some(true) })); @@ -1997,12 +2019,21 @@ describe("legacy db pull", () => { }); it.effect("db pull --local keeps migration repair suggestions local", () => { - seedMigration(tmp.current, "20240102000000"); - const s = setup(tmp.current, { remoteVersions: ["20240101000000"] }); + const s = setup(tmp.current, { + remoteVersions: ["20240101000000"], + fixtures: [seedMigration(tmp.current, "20240102000000")], + }); return Effect.gen(function* () { const exit = yield* legacyDbPull(flags({ local: Option.some(true) })).pipe(Effect.exit); - expect(JSON.stringify(exit)).toContain("migration repair --local --status reverted"); - expect(JSON.stringify(exit)).toContain("migration repair --local --status applied"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain( + "migration repair --local --status reverted", + ); + expect(Formatter.formatJson(exit.cause)).toContain( + "migration repair --local --status applied", + ); + } }).pipe(Effect.provide(s.layer)); }); @@ -2012,11 +2043,11 @@ describe("legacy db pull", () => { // The repair globs `<timestamp>_*.sql`, which fails when the name has a path // separator (the file is nested), so the native path must not silently upsert // an empty-version migration-history row. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { const exit = yield* legacyDbPull(flags({ name: Option.some("foo/bar") })).pipe(Effect.exit); @@ -2034,11 +2065,11 @@ describe("legacy db pull", () => { // glob `<generated>_*.sql` never crosses the `/`, so it misses and fails. // Anchoring on the generated timestamp must reject this rather than upserting // the user's nested timestamp as applied. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", yes: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { const exit = yield* legacyDbPull( @@ -2054,13 +2085,13 @@ describe("legacy db pull", () => { // Regression: json/stream-json layers fail every prompt as non-interactive, so // the history-update prompt must be skipped (default = yes) instead of failing // the command before the structured success payload is emitted. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { format: "json", remoteVersions: ["20240101000000"], edgeStdout: "create table remote ();\n", stdinIsTty: true, // no --yes + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull(flags()); @@ -2075,27 +2106,29 @@ describe("legacy db pull", () => { // experimental.pgdelta.enabled is read. Base config disables pg-delta; the // remote override enables it, so the migration-style pull must pick the // pg-delta engine. - seedMigration(tmp.current, "20240101000000"); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[experimental.pgdelta]", - "enabled = false", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, resolvedRef: "abcdefghijklmnopqrst", + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase")), + writeText( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = false", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); @@ -2116,15 +2149,17 @@ describe("legacy db pull", () => { // `db.migrations.enabled = "notabool"` fails `legacyReadDbToml`'s own bool // parse AFTER the ref is already known, exercising exactly that gap // (`diff.integration.test.ts`'s identical fix/test). - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - ["[db.migrations]", 'enabled = "notabool"', ""].join("\n"), - ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], yes: true, resolvedRef: "abcdefghijklmnopqrst", + fixtures: [ + makeDirectory(join(tmp.current, "supabase")), + writeText( + join(tmp.current, "supabase", "config.toml"), + ["[db.migrations]", 'enabled = "notabool"', ""].join("\n"), + ), + ], }); return Effect.gen(function* () { const exit = yield* legacyDbPull(flags({ linked: Option.some(true) })).pipe(Effect.exit); @@ -2147,27 +2182,29 @@ describe("legacy db pull", () => { // that emits a `--tmpfs` flag on the shadow's `docker create` argv // (`legacyBuildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) // overridden by a remote block's `major_version = 14` must flip that flag on. - seedMigration(tmp.current, "20240101000000"); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[db]", - "major_version = 17", - "", - "[remotes.staging]", - 'project_id = "abcdefghijklmnopqrst"', - "", - "[remotes.staging.db]", - "major_version = 14", - "", - ].join("\n"), - ); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "alter table x;\n", yes: true, resolvedRef: "abcdefghijklmnopqrst", + fixtures: [ + seedMigration(tmp.current, "20240101000000"), + makeDirectory(join(tmp.current, "supabase")), + writeText( + join(tmp.current, "supabase", "config.toml"), + [ + "[db]", + "major_version = 17", + "", + "[remotes.staging]", + 'project_id = "abcdefghijklmnopqrst"', + "", + "[remotes.staging.db]", + "major_version = 14", + "", + ].join("\n"), + ), + ], }); return Effect.gen(function* () { yield* legacyDbPull(flags({ linked: Option.some(true) })); @@ -2189,13 +2226,13 @@ describe("legacy db pull", () => { // shadow and re-prints both banners, rather than reusing the first attempt's // shadow. Assert that shape directly, not just that the migration eventually // gets written. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeFailFirstWith: "error diffing schema:\nfailed to connect: network is unreachable", edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, poolerAvailable: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { yield* legacyDbPull( @@ -2243,12 +2280,12 @@ describe("legacy db pull", () => { it.effect("an IPv6 diff error with no pooler available surfaces the original error", () => { // A pooler resolution failure surfaces the ORIGINAL diff error rather than a // retry error. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeFailFirstWith: "error diffing schema:\nnetwork is unreachable", yes: true, poolerAvailable: false, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { const exit = yield* legacyDbPull( @@ -2263,12 +2300,12 @@ describe("legacy db pull", () => { it.effect("a non-IPv6 diff error is not retried through the pooler", () => { // Only IPv6 connectivity errors are eligible; any other failure surfaces as-is // without consulting the pooler. - seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeFailFirstWith: 'error diffing schema:\nsyntax error at or near "foo"', yes: true, poolerAvailable: true, + fixtures: [seedMigration(tmp.current, "20240101000000")], }); return Effect.gen(function* () { const exit = yield* legacyDbPull( diff --git a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts index bdb4119e38..0d7ca61b21 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-random, effecttsgo/node-builtin-import -- this live test owns temporary host files and unique external database names. import { mkdir, readdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts index 333f6398f0..7a6d253bcf 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts @@ -1,6 +1,3 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; @@ -40,23 +37,24 @@ function mockSession(opts: { readonly failUpsertAt?: number } = {}) { return { session, calls }; } -function writeMigrations(dir: string): ReadonlyArray<LegacyPulledMigration> { - const migrations: ReadonlyArray<LegacyPulledMigration> = [ - { path: join(dir, "20240101000000_a.sql"), version: "20240101000000" }, - { path: join(dir, "20240101000001_b.sql"), version: "20240101000001" }, - ]; - writeFileSync(migrations[0]!.path, "create table a ();"); - writeFileSync(migrations[1]!.path, "create table b ();"); - return migrations; -} +const writeMigrations = (fs: FileSystem.FileSystem, path: Path.Path, dir: string) => + Effect.gen(function* () { + const migrations: ReadonlyArray<LegacyPulledMigration> = [ + { path: path.join(dir, "20240101000000_a.sql"), version: "20240101000000" }, + { path: path.join(dir, "20240101000001_b.sql"), version: "20240101000001" }, + ]; + yield* fs.writeFileString(migrations[0]!.path, "create table a ();"); + yield* fs.writeFileString(migrations[1]!.path, "create table b ();"); + return migrations; + }); describe("legacyUpdateMigrationHistory", () => { it.effect("wraps the upserts in one BEGIN + N upserts + COMMIT transaction", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = mkdtempSync(join(tmpdir(), "pull-sync-")); - const migrations = writeMigrations(dir); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pull-sync-" }); + const migrations = yield* writeMigrations(fs, path, dir); const out = mockOutput(); const { session, calls } = mockSession(); @@ -79,8 +77,8 @@ describe("legacyUpdateMigrationHistory", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const dir = mkdtempSync(join(tmpdir(), "pull-sync-")); - const migrations = writeMigrations(dir); + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "pull-sync-" }); + const migrations = yield* writeMigrations(fs, path, dir); const out = mockOutput(); const { session, calls } = mockSession({ failUpsertAt: 2 }); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts index c7b8398fe3..8e6b52a4d1 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.ts @@ -42,11 +42,9 @@ export const legacyUpdateMigrationHistory = ( for (const migration of migrations) { const match = MIGRATE_FILE_PATTERN.exec(path.basename(migration.path)); if (match === null || match[1] !== migration.version) { - return yield* Effect.fail( - new LegacyDbPullWriteError({ - message: `glob supabase/migrations/${migration.version}_*.sql: file does not exist`, - }), - ); + return yield* new LegacyDbPullWriteError({ + message: `glob supabase/migrations/${migration.version}_*.sql: file does not exist`, + }); } resolved.push({ version: migration.version, diff --git a/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts b/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts index e8f17a954f..49f625bc67 100644 --- a/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.e2e.test.ts @@ -1,8 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase } from "../../../../../tests/helpers/cli.ts"; @@ -12,41 +10,55 @@ const UNREACHABLE_DB_URL = "postgresql://postgres:postgres@127.0.0.1:1/postgres" describe("supabase db push --skip-vault (legacy)", () => { let projectDir: string; - beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-db-push-skip-vault-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync( - join(projectDir, "supabase", "config.toml"), - '[db.vault]\nmy_secret = "encrypted:not-valid"\n', - ); - }); + beforeAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + projectDir = yield* fs.makeTempDirectory({ + prefix: "supabase-db-push-skip-vault-e2e-", + }); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + '[db.vault]\nmy_secret = "encrypted:not-valid"\n', + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); + afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - test("fails during config loading without the flag", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stderr } = await runSupabase(["db", "push", "--db-url", UNREACHABLE_DB_URL], { + test("fails during config loading without the flag", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["db", "push", "--db-url", UNREACHABLE_DB_URL], { entrypoint: "legacy", cwd: projectDir, - }); - expect(exitCode).toBe(1); - expect(stderr).toContain("failed to parse config:"); - expect(stderr).not.toContain("Connecting to remote database..."); - }); + }).then(({ exitCode, stderr }) => { + expect(exitCode).toBe(1); + expect(stderr).toContain("failed to parse config:"); + expect(stderr).not.toContain("Connecting to remote database..."); + }), + ); test( "reaches the database connection without decrypting vault secrets", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase( - ["db", "push", "--db-url", UNREACHABLE_DB_URL, "--skip-vault"], - { entrypoint: "legacy", cwd: projectDir }, - ); - expect(exitCode).toBe(1); - expect(stderr).toContain("Connecting to remote database..."); - expect(stderr).toContain("failed to connect"); - expect(stderr).not.toContain("failed to parse config:"); - }, + () => + runSupabase(["db", "push", "--db-url", UNREACHABLE_DB_URL, "--skip-vault"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ exitCode, stderr }) => { + expect(exitCode).toBe(1); + expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toContain("failed to connect"); + expect(stderr).not.toContain("failed to parse config:"); + }), ); }); diff --git a/apps/cli/src/legacy/commands/db/push/push.handler.ts b/apps/cli/src/legacy/commands/db/push/push.handler.ts index 6703c370d6..3d19af16b9 100644 --- a/apps/cli/src/legacy/commands/db/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/db/push/push.handler.ts @@ -46,16 +46,17 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy let linkedRefForCache: string | undefined; const body = Effect.gen(function* () { - yield* legacyApplyProjectEnv(projectEnv); + const effectiveProjectEnv = { + ...projectEnv, + ...(yield* legacyApplyProjectEnv(projectEnv)), + }; const target = resolveLegacyDbTargetFlags(cliArgs.args); // Mutually-exclusive db-url/linked/local group, keyed off the // explicitly-set flags, not the `--linked` default value. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyDbPushTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbPushTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } // push defaults `--linked` to true, so no target flag → linked. const connType = target.connType ?? "linked"; @@ -67,12 +68,10 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy // typed `--project-ref` flag silently doing nothing on e.g. `db push // --local` is a footgun the env var doesn't share, so this errors instead. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyDbPushTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbPushTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // The linked path resolves the project ref before loading config so a @@ -128,7 +127,7 @@ export const legacyDbPush = Effect.fn("legacy.db.push")(function* (flags: Legacy includeVault: !flags.skipVault, dnsResolver, projectId: cliConfig.projectId, - toml, + toml: { ...toml, projectEnv: { ...toml.projectEnv, ...effectiveProjectEnv } }, yes, emitStructuredResult: true, }); diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index f8b411d56c..2cf12e06c3 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -1,10 +1,8 @@ import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { basename, dirname, join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { @@ -17,6 +15,7 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; @@ -68,6 +67,30 @@ const DEFAULT_FLAGS: LegacyDbPushFlags = { password: Option.none(), }; +const testPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const join = (...parts: ReadonlyArray<string>) => testPath.join(...parts); +const basename = (path: string) => testPath.basename(path); + +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const exists = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }).pipe(Effect.provide(BunServices.layer)); + +const readDirectory = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(path); + }).pipe(Effect.provide(BunServices.layer)); + +const readFileString = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }).pipe(Effect.provide(BunServices.layer)); + function mockResolver( opts: { isLocal?: boolean; onResolve?: (flags: LegacyDbConfigFlags) => void } = {}, ) { @@ -169,6 +192,7 @@ function setup( opts: { toml?: string; files?: Readonly<Record<string, string>>; + binaryFiles?: Readonly<Record<string, Uint8Array>>; format?: OutputFormat; confirm?: ReadonlyArray<boolean>; args?: ReadonlyArray<string>; @@ -185,6 +209,7 @@ function setup( catalogStdout?: string; catalogExportFailWith?: string; noProjectId?: boolean; + env?: Readonly<Record<string, string>>; // Simulates the real `LegacyDbConfigResolver`'s own "Initialising login // role..." stderr line (`legacy-db-config.layer.ts`'s `initLoginRole`), // fired as part of `resolve()`'s own connection-resolution work — i.e. @@ -194,15 +219,31 @@ function setup( simulateInitialisingLoginRole?: boolean; }, ) { - if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); - } - for (const [rel, content] of Object.entries(opts.files ?? {})) { - const abs = join(workdir, rel); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); - } + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); + const fixtureLayer = Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (opts.toml !== undefined) { + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), opts.toml); + } + for (const [rel, content] of Object.entries(opts.files ?? {})) { + const abs = path.join(workdir, rel); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFileString(abs, content); + } + for (const [rel, content] of Object.entries(opts.binaryFiles ?? {})) { + const abs = path.join(workdir, rel); + yield* fs.makeDirectory(path.dirname(abs), { recursive: true }); + yield* fs.writeFile(abs, content); + } + }).pipe(Effect.provide(BunServices.layer)), + ); const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const conn = mockConnection(opts); @@ -214,7 +255,7 @@ function setup( const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { edgeRunCalls.push(runOpts); - registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + registryEnvAtRunTime.push(runOpts.projectEnvValues?.["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); if (opts.catalogExportFailWith !== undefined) { return Effect.fail( new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), @@ -265,6 +306,8 @@ function setup( ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), }), BunServices.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), // Prompts (migration/seed confirmation) are answered through mockOutput's // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is // only referenced by legacyPromptYesNo's non-TTY branch (unreached here). @@ -278,9 +321,11 @@ function setup( linkedCache.layer, edge, sslProbe, + fixtureLayer, ); return { layer, + configProvider, out, conn, telemetry, @@ -404,7 +449,7 @@ describe("legacy db push", () => { yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(edgeRunCalls).toHaveLength(0); expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); + expect(yield* exists(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); }); }); @@ -417,7 +462,7 @@ describe("legacy db push", () => { return Effect.gen(function* () { yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(edgeRunCalls).toHaveLength(0); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); + expect(yield* exists(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); }); }); @@ -436,7 +481,7 @@ describe("legacy db push", () => { expect(out.stderrText).not.toContain("failed to cache migrations catalog"); expect(edgeRunCalls).toHaveLength(1); const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => + const catalogFiles = (yield* readDirectory(tempDir)).filter((name) => name.startsWith("catalog-local-migrations-"), ); expect(catalogFiles).toHaveLength(1); @@ -451,8 +496,6 @@ describe("legacy db push", () => { // `supabase/.env` fallback below and resolve to the next implementation — // matching the engine-selector layer's own precedence rather than // `toml.envLookup`'s (which treats an empty shell value as unset). - const prev = process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - process.env["SUPABASE_USE_PG_DELTA_NEXT"] = ""; const { layer, out, edgeRunCalls } = setup(tmp.current, { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', files: { @@ -460,20 +503,14 @@ describe("legacy db push", () => { "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", }, confirm: [true], + env: { SUPABASE_USE_PG_DELTA_NEXT: "" }, }); return Effect.gen(function* () { yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).not.toContain("failed to cache migrations catalog"); expect(edgeRunCalls).toHaveLength(0); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - else process.env["SUPABASE_USE_PG_DELTA_NEXT"] = prev; - }), - ), - ); + expect(yield* exists(join(tmp.current, "supabase", ".temp", "pgdelta"))).toBe(false); + }); }, ); @@ -492,11 +529,11 @@ describe("legacy db push", () => { expect(out.stderrText).not.toContain("failed to cache migrations catalog"); expect(edgeRunCalls).toHaveLength(1); const tempDir = join(tmp.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => + const catalogFiles = (yield* readDirectory(tempDir)).filter((name) => name.startsWith("catalog-local-migrations-"), ); expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); + expect(yield* readFileString(join(tempDir, catalogFiles[0]!))).toBe('{"snapshot":"ok"}'); }); }); @@ -630,8 +667,6 @@ describe("legacy db push", () => { it.live( "resolves the pg-delta cache export image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", () => { - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; const { layer, registryEnvAtRunTime } = setup(tmp.current, { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', files: { @@ -641,22 +676,32 @@ describe("legacy db push", () => { }, confirm: [true], catalogStdout: '{"snapshot":"ok"}', + env: {}, }); return Effect.gen(function* () { yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - ); + }); }, ); + it.live("propagates an ambient registry value into downstream pg-delta image resolution", () => { + const { layer, registryEnvAtRunTime } = setup(tmp.current, { + toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', + files: { + ...migrationFile("20240101000000"), + "supabase/.env": "SUPABASE_USE_PG_DELTA_NEXT=false\n", + }, + confirm: [true], + catalogStdout: '{"snapshot":"ok"}', + env: { SUPABASE_INTERNAL_IMAGE_REGISTRY: "ambient-mirror.example.com" }, + }); + return Effect.gen(function* () { + yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); + expect(registryEnvAtRunTime).toEqual(["ambient-mirror.example.com"]); + }); + }); + it.live("returns context canceled when the migration prompt is declined", () => { const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -667,7 +712,7 @@ describe("legacy db push", () => { const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("context canceled"); + expect(encodeJson(exit.cause)).toContain("context canceled"); } expect(conn.execs).not.toContain("BEGIN"); }); @@ -740,11 +785,11 @@ describe("legacy db push", () => { const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(encodeJson(exit.cause)).toContain( "Remote migration versions not found in local migrations directory.", ); - expect(JSON.stringify(exit.cause)).toContain("migration repair --local --status reverted"); - expect(JSON.stringify(exit.cause)).toContain("supabase db pull --local"); + expect(encodeJson(exit.cause)).toContain("migration repair --local --status reverted"); + expect(encodeJson(exit.cause)).toContain("supabase db pull --local"); } expect(out).toBeDefined(); }); @@ -763,8 +808,8 @@ describe("legacy db push", () => { dbUrl: Option.some(dbUrl), local: false, }).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("migration repair --status reverted"); - expect(JSON.stringify(exit)).not.toContain("migration repair --local"); + expect(encodeJson(exit)).toContain("migration repair --status reverted"); + expect(encodeJson(exit)).not.toContain("migration repair --local"); }); }); @@ -779,7 +824,7 @@ describe("legacy db push", () => { const exit = yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("--include-all"); + expect(encodeJson(exit.cause)).toContain("--include-all"); } }); }); @@ -873,9 +918,8 @@ describe("legacy db push", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', remoteSeeds: { "supabase/seed.sql": rawHash }, + binaryFiles: { "supabase/seed.sql": raw }, }); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "seed.sql"), raw); return Effect.gen(function* () { yield* legacyDbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); expect(out.stdoutText).toBe("Local database is up to date.\n"); @@ -965,7 +1009,7 @@ describe("legacy db push", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + if (Exit.isFailure(exit)) expect(encodeJson(exit.cause)).toContain("context canceled"); }); }); @@ -981,7 +1025,7 @@ describe("legacy db push", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + if (Exit.isFailure(exit)) expect(encodeJson(exit.cause)).toContain("context canceled"); }); }); @@ -1101,7 +1145,7 @@ describe("legacy db push", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("failed to parse config:"); + expect(encodeJson(exit)).toContain("failed to parse config:"); expect(out.stderrText).not.toContain("Connecting to local database..."); expect(conn.queries).toEqual([]); }); @@ -1273,7 +1317,7 @@ describe("legacy db push", () => { // malformed config aborts with the established `failed to load config` // message (the reader path), same as the other db commands // (diff/dump/pull/migration). - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + expect(encodeJson(exit.cause)).toContain("failed to load config"); } }); }); @@ -1282,24 +1326,16 @@ describe("legacy db push", () => { // Regression for the strict @supabase/config loader rejecting `enabled = "env(VAR)"`: // env-expansion + boolean parsing must resolve it, so the config loads and the // migration proceeds. Previously native push aborted before that parse ran. - const previous = process.env["SEED_ENABLED"]; - process.env["SEED_ENABLED"] = "true"; const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nenabled = "env(SEED_ENABLED)"\n', files: migrationFile("20240101000000"), confirm: [true], + env: { SEED_ENABLED: "true" }, }); return Effect.gen(function* () { yield* legacyDbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SEED_ENABLED"]; - else process.env["SEED_ENABLED"] = previous; - }), - ), - ); + }); }); it.live("a matched remote block's migrations.enabled beats the shell env override", () => { @@ -1308,8 +1344,6 @@ describe("legacy db push", () => { // `SUPABASE_DB_MIGRATIONS_ENABLED=true` and the push skips migrations. // (Before the config-reader convergence, push resolved this gate // env-first and wrongly applied.) - const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; - process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "true"; const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.preview]\nproject_id = "${LEGACY_VALID_REF}"\n\n[remotes.preview.db.migrations]\nenabled = false\n`, files: migrationFile("20240101000000"), @@ -1317,6 +1351,7 @@ describe("legacy db push", () => { isLocal: false, projectRef: LEGACY_VALID_REF, confirm: [true], + env: { SUPABASE_DB_MIGRATIONS_ENABLED: "true" }, }); return Effect.gen(function* () { yield* legacyDbPush({ ...DEFAULT_FLAGS, local: false, linked: true }).pipe( @@ -1324,14 +1359,7 @@ describe("legacy db push", () => { ); expect(out.stderrText).toContain("Skipping migrations because it is disabled"); expect(out.stderrText).not.toContain("Applying migration 20240101000000"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; - else process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = previous; - }), - ), - ); + }); }); it.live("announces a matching [remotes.*] override on the linked path", () => { @@ -1456,7 +1484,7 @@ describe("legacy db push", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(encodeJson(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } diff --git a/apps/cli/src/legacy/commands/db/push/push.live.test.ts b/apps/cli/src/legacy/commands/db/push/push.live.test.ts index d4d4ed890f..84699538bc 100644 --- a/apps/cli/src/legacy/commands/db/push/push.live.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-random, effecttsgo/node-builtin-import -- this live test owns temporary host files and unique external database names. import { mkdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/db/query/query.format.ts b/apps/cli/src/legacy/commands/db/query/query.format.ts index 0f8350a3a4..666464c114 100644 --- a/apps/cli/src/legacy/commands/db/query/query.format.ts +++ b/apps/cli/src/legacy/commands/db/query/query.format.ts @@ -1,4 +1,4 @@ -import { Option } from "effect"; +import { DateTime, Option } from "effect"; import { legacyGoFormatFloat } from "../../../shared/legacy-go-float.ts"; import { legacyStringWidth } from "../../../shared/legacy-rune-width.ts"; @@ -42,7 +42,7 @@ function goFormatValue(value: unknown): string { const keys = Object.keys(obj).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); return `map[${keys.map((k) => `${k}:${goFormatValue(obj[k])}`).join(" ")}]`; } - return String(value); + return Object.prototype.toString.call(value); } /** @@ -54,8 +54,10 @@ function goFormatValue(value: unknown): string { export function legacyFormatValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; if (typeof value === "string") return value; + if (typeof value === "number") return value.toString(); + if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "object") return goFormatValue(value); - return String(value); + return Object.prototype.toString.call(value); } /** @@ -121,23 +123,30 @@ function parsePgUtcInstant(raw: string): PgUtcInstant | undefined { // `Date.UTC` remaps years 0–99 to 1900–1999, which would corrupt historical dates // (`0001-01-01` → `1901-...`). `setUTCFullYear` does not remap, so build the instant // explicitly to preserve the original year. - const dt = new Date(0); - dt.setUTCFullYear(Number(y), Number(mo) - 1, Number(d)); - dt.setUTCHours(Number(hh ?? "0"), Number(mi ?? "0"), Number(ss ?? "0"), 0); - let utcMs = dt.getTime(); + const dt = DateTime.makeUnsafe({ + year: Number(y), + month: Number(mo), + day: Number(d), + hour: Number(hh ?? "0"), + minute: Number(mi ?? "0"), + second: Number(ss ?? "0"), + millisecond: 0, + }); + let utcMs = dt.epochMilliseconds; if (sign !== undefined) { // The text offset is the zone's offset from UTC; subtract it to reach UTC. const offsetSeconds = Number(oh) * 3600 + Number(om ?? "0") * 60 + Number(os ?? "0"); utcMs -= (sign === "-" ? -offsetSeconds : offsetSeconds) * 1000; } - const u = new Date(utcMs); + const u = DateTime.makeUnsafe(utcMs); + const parts = DateTime.toPartsUtc(u); return { - year: u.getUTCFullYear(), - month: u.getUTCMonth() + 1, - day: u.getUTCDate(), - hour: u.getUTCHours(), - minute: u.getUTCMinutes(), - second: u.getUTCSeconds(), + year: parts.year, + month: parts.month, + day: parts.day, + hour: parts.hour, + minute: parts.minute, + second: parts.second, fraction: (frac ?? "").replace(/0+$/, ""), }; } diff --git a/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts b/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts index a433bd76a8..fdf45055a7 100644 --- a/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/query/query.format.unit.test.ts @@ -1,4 +1,4 @@ -import { Option } from "effect"; +import { DateTime, Option, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { legacyBuildRlsAdvisory } from "./query.advisory.ts"; @@ -102,8 +102,10 @@ describe("legacyMakeLocalCellFormatter", () => { it("renders Date (timestamp) cells like Go's time.Time %v instead of map[]", () => { const fmt = legacyMakeLocalCellFormatter([1114]); - expect(fmt(new Date(Date.UTC(2024, 0, 2, 15, 4, 5)), 0)).toBe("2024-01-02 15:04:05 +0000 UTC"); - expect(fmt(new Date(Date.UTC(2024, 0, 2, 15, 4, 5, 123)), 0)).toBe( + expect(fmt(DateTime.toDate(DateTime.makeUnsafe(Date.UTC(2024, 0, 2, 15, 4, 5))), 0)).toBe( + "2024-01-02 15:04:05 +0000 UTC", + ); + expect(fmt(DateTime.toDate(DateTime.makeUnsafe(Date.UTC(2024, 0, 2, 15, 4, 5, 123))), 0)).toBe( "2024-01-02 15:04:05.123 +0000 UTC", ); }); @@ -297,7 +299,15 @@ describe("legacyRenderJson", () => { expect(out).toContain("\\u003cdeadbeef\\u003e"); expect(out).not.toContain("<deadbeef>"); expect(out.endsWith("\n")).toBe(true); - const parsed = JSON.parse(out); + const parsed = Schema.decodeSync( + Schema.fromJsonString( + Schema.Struct({ + boundary: Schema.String, + rows: Schema.Array(Schema.Unknown), + advisory: Schema.optional(Schema.Unknown), + }), + ), + )(out); expect(parsed.boundary).toBe("deadbeef"); expect(parsed.rows).toEqual([{ id: 1 }]); expect(parsed.advisory).toBeUndefined(); @@ -307,7 +317,20 @@ describe("legacyRenderJson", () => { const advisory = legacyBuildRlsAdvisory(["public.users"]); const out = legacyRenderJson(["id"], [[1]], true, "ab", advisory); expect(out.indexOf('"advisory"')).toBeLessThan(out.indexOf('"boundary"')); - const parsed = JSON.parse(out); + const parsed = Schema.decodeSync( + Schema.fromJsonString( + Schema.Struct({ + boundary: Schema.String, + rows: Schema.Array(Schema.Unknown), + advisory: Schema.Struct({ + id: Schema.String, + remediation_sql: Schema.String, + priority: Schema.Finite, + level: Schema.String, + }), + }), + ), + )(out); expect(parsed.advisory.id).toBe("rls_disabled"); expect(parsed.advisory.remediation_sql).toBe( "ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;", diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 2b03be1af1..5aaccb6f4a 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path, Redacted } from "effect"; +import { DateTime, Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -19,10 +19,32 @@ import { LegacyOutputFlag, } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { legacyErrorMessage } from "../../../shared/legacy-error-message.ts"; import { Random } from "../../../../shared/runtime/random.service.ts"; import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; import { AiTool } from "../../../../shared/telemetry/ai-tool.service.ts"; import type { LegacyDbQueryFlags } from "./query.command.ts"; + +const queryCellToString = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; + +function isQueryRowArray(value: unknown): value is ReadonlyArray<Record<string, unknown> | null> { + return ( + Array.isArray(value) && + value.every( + (element) => + element === null || + (typeof element === "object" && element !== null && !Array.isArray(element)), + ) + ); +} import { LEGACY_RLS_CHECK_SQL, legacyBuildRlsAdvisory } from "./query.advisory.ts"; import { LegacyDbQueryExecError, @@ -112,11 +134,9 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega // Checked before any output. const nonFinite = legacyFindNonFiniteJsonValue(data); if (nonFinite !== undefined) { - return yield* Effect.fail( - new LegacyDbQueryExecError({ - message: `failed to encode JSON: json: unsupported value: ${nonFinite}`, - }), - ); + return yield* new LegacyDbQueryExecError({ + message: `failed to encode JSON: json: unsupported value: ${nonFinite}`, + }); } const jsonData = fieldTypeIds === undefined ? data : legacyCoerceLocalJsonRows(data, fieldTypeIds); @@ -124,8 +144,9 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega const rendered = legacyRenderJson(cols, jsonData, agentMode, boundary, advisory); if (output.format === "stream-json" && Option.getOrUndefined(outputFlag) !== "json") { const compactRendered = rendered.trimEnd().replaceAll("\n", ""); + const timestamp = DateTime.formatIso(yield* DateTime.now); yield* output.raw( - `{"type":"result","data":${compactRendered},"timestamp":${JSON.stringify(new Date().toISOString())}}\n`, + `{"type":"result","data":${compactRendered},"timestamp":"${timestamp}"}\n`, ); return; } @@ -157,7 +178,7 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega const advisory = agentMode ? yield* session.queryRaw(LEGACY_RLS_CHECK_SQL).pipe( Effect.map((rls) => - legacyBuildRlsAdvisory(rls.rows.map((row) => String(row[0] ?? ""))), + legacyBuildRlsAdvisory(rls.rows.map((row) => queryCellToString(row[0]))), ), Effect.orElseSucceed(() => Option.none<LegacyAdvisory>()), ) @@ -202,38 +223,32 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega Effect.mapError( (cause) => new LegacyDbQueryExecError({ - message: `failed to execute query: ${cause}`, + message: `failed to execute query: ${legacyErrorMessage(cause)}`, transport: true, }), ), ); if (status !== 201) { - return yield* Effect.fail( - new LegacyDbQueryUnexpectedStatusError({ - status, - message: `unexpected status ${status}: ${body}`, - }), - ); + return yield* new LegacyDbQueryUnexpectedStatusError({ + status, + message: `unexpected status ${status}: ${body}`, + }); } // The API returns a JSON array of row objects for SELECT, or a plain // command tag for DDL/DML. Anything that is not a JSON array of objects // is printed verbatim (the array-of-maps decode fails → raw body). - let parsed: unknown; - try { - parsed = JSON.parse(body); - } catch { + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(body).pipe( + Effect.option, + ); + if (Option.isNone(parsed)) { return yield* output.raw(`${body}\n`); } - const isRowArray = - Array.isArray(parsed) && - parsed.every( - (element) => element === null || (typeof element === "object" && !Array.isArray(element)), - ); - if (!isRowArray) { + const parsedValue = parsed.value; + if (!isQueryRowArray(parsedValue)) { return yield* output.raw(`${body}\n`); } - const rows = parsed as ReadonlyArray<Record<string, unknown> | null>; + const rows = parsedValue; if (rows.length === 0) { return yield* emit(format, [], [], agentMode, Option.none()); } @@ -252,23 +267,19 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega if (Option.isSome(flags.linked)) exclusive.push("linked"); if (Option.isSome(flags.local)) exclusive.push("local"); if (exclusive.length > 1) { - return yield* Effect.fail( - new LegacyDbQueryMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${exclusive.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbQueryMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${exclusive.join(" ")}] were all set`, + }); } // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target — see push.handler.ts's identical guard // for the full TS-only rationale. if (Option.isSome(flags.projectRef) && Option.isNone(flags.linked)) { - return yield* Effect.fail( - new LegacyDbQueryMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbQueryMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // PreRun parity: for --linked, the access token is checked and the @@ -319,12 +330,10 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega // `/database/query`. const tokenOpt = yield* credentials.getAccessToken; if (Option.isNone(tokenOpt)) { - return yield* Effect.fail( - new LegacyDbQueryLoginRequiredError({ - message: MISSING_TOKEN_MESSAGE, - suggestion: "Run supabase login first.", - }), - ); + return yield* new LegacyDbQueryLoginRequiredError({ + message: MISSING_TOKEN_MESSAGE, + suggestion: "Run supabase login first.", + }); } linkedAuth = { token: tokenOpt.value, ref }; } @@ -367,17 +376,13 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega if (!stdin.isTTY) { const piped = yield* stdin.readPipedText; if (Option.isNone(piped)) { - return yield* Effect.fail( - new LegacyDbQueryNoStdinSqlError({ message: "no SQL provided via stdin" }), - ); + return yield* new LegacyDbQueryNoStdinSqlError({ message: "no SQL provided via stdin" }); } return piped.value; } - return yield* Effect.fail( - new LegacyDbQueryNoSqlError({ - message: "no SQL query provided. Pass SQL as an argument, via --file, or pipe to stdin", - }), - ); + return yield* new LegacyDbQueryNoSqlError({ + message: "no SQL query provided. Pass SQL as an argument, via --file, or pipe to stdin", + }); }); // 2. Agent mode + the resolved payload format: an explicit `-o diff --git a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts index 6b54b09ca1..8fbb54592e 100644 --- a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts @@ -1,9 +1,17 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Redacted, Stream } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Redacted, + Schema, + Stream, +} from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -44,6 +52,16 @@ import { LEGACY_RLS_CHECK_SQL } from "./query.advisory.ts"; import type { LegacyDbQueryFlags } from "./query.command.ts"; import { legacyDbQuery } from "./query.handler.ts"; +const queryJsonEnvelopeSchema = Schema.Struct({ + boundary: Schema.String, + rows: Schema.Unknown, + advisory: Schema.optional(Schema.Unknown), +}); +const queryJsonAdvisorySchema = Schema.Struct({ + advisory: Schema.Struct({ id: Schema.String }), +}); +const queryJsonStreamSchema = Schema.Struct({ type: Schema.String, data: Schema.Unknown }); + const LOCAL_CONN: LegacyPgConnInput = { host: "127.0.0.1", port: 54322, @@ -358,31 +376,34 @@ describe("legacy db query integration", () => { }); it.live("reads SQL from --file", () => { - const { layer, out } = setup({ result: SELECT_RESULT }); - const filePath = join(mkdtempSync(join(tmpdir(), "supabase-query-")), "q.sql"); - writeFileSync(filePath, "select * from users"); return Effect.gen(function* () { - yield* legacyDbQuery(flags({ local: Option.some(true), file: Option.some(filePath) })); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-query-" }); + const filePath = path.join(root, "q.sql"); + yield* fs.writeFileString(filePath, "select * from users"); + const { layer, out } = setup({ result: SELECT_RESULT }); + yield* legacyDbQuery(flags({ local: Option.some(true), file: Option.some(filePath) })).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toContain("alice"); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(filePath, { force: true }))), - ); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("resolves a relative --file against the workdir", () => { // A relative `--file` path resolves against the workdir, not the // original process cwd. - const dir = mkdtempSync(join(tmpdir(), "supabase-query-wd-")); - writeFileSync(join(dir, "q.sql"), "select * from users"); - const { layer, out } = setup({ result: SELECT_RESULT, workdir: dir }); return Effect.gen(function* () { - yield* legacyDbQuery(flags({ local: Option.some(true), file: Option.some("q.sql") })); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-query-wd-" }); + yield* fs.writeFileString(path.join(dir, "q.sql"), "select * from users"); + const { layer, out } = setup({ result: SELECT_RESULT, workdir: dir }); + yield* legacyDbQuery(flags({ local: Option.some(true), file: Option.some("q.sql") })).pipe( + Effect.provide(layer), + ); expect(out.stdoutText).toContain("alice"); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("errors when --file cannot be read", () => { @@ -417,7 +438,9 @@ describe("legacy db query integration", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes" }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); - const parsed = JSON.parse(out.stdoutText); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(queryJsonEnvelopeSchema))( + out.stdoutText, + ); expect(parsed.boundary).toBe(BOUNDARY); expect(parsed.rows).toEqual([ { id: 1, name: "alice" }, @@ -431,7 +454,10 @@ describe("legacy db query integration", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "auto", aiTool: "cursor" }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); - expect(JSON.parse(out.stdoutText).boundary).toBe(BOUNDARY); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(queryJsonEnvelopeSchema))( + out.stdoutText, + ); + expect(parsed.boundary).toBe(BOUNDARY); }).pipe(Effect.provide(layer)); }); @@ -439,7 +465,9 @@ describe("legacy db query integration", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", goOutput: "json" }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); - const parsed = JSON.parse(out.stdoutText); + const parsed = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(Schema.Unknown)), + )(out.stdoutText); expect(Array.isArray(parsed)).toBe(true); expect(parsed).toEqual([ { id: 1, name: "alice" }, @@ -452,7 +480,10 @@ describe("legacy db query integration", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "no", format: "json" }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); - expect(JSON.parse(out.stdoutText)).toEqual([ + const parsed = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Array(Schema.Unknown)), + )(out.stdoutText); + expect(parsed).toEqual([ { id: 1, name: "alice" }, { id: 2, name: "bob" }, ]); @@ -464,7 +495,10 @@ describe("legacy db query integration", () => { return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); expect(out.stdoutText.trimEnd().split("\n")).toHaveLength(1); - expect(JSON.parse(out.stdoutText)).toEqual( + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(queryJsonStreamSchema))( + out.stdoutText, + ); + expect(parsed).toEqual( expect.objectContaining({ type: "result", data: [ @@ -581,7 +615,10 @@ describe("legacy db query integration", () => { }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); - expect(JSON.parse(out.stdoutText).advisory.id).toBe("rls_disabled"); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(queryJsonAdvisorySchema))( + out.stdoutText, + ); + expect(parsed.advisory.id).toBe("rls_disabled"); }).pipe(Effect.provide(layer)); }); @@ -589,7 +626,10 @@ describe("legacy db query integration", () => { const { layer, out } = setup({ result: SELECT_RESULT, agent: "yes", rlsFails: true }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), local: Option.some(true) })); - expect(JSON.parse(out.stdoutText).advisory).toBeUndefined(); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(queryJsonEnvelopeSchema))( + out.stdoutText, + ); + expect(parsed.advisory).toBeUndefined(); }).pipe(Effect.provide(layer)); }); @@ -877,7 +917,9 @@ describe("legacy db query integration", () => { }); return Effect.gen(function* () { yield* legacyDbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); - const parsed = JSON.parse(out.stdoutText); + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(queryJsonEnvelopeSchema))( + out.stdoutText, + ); expect(parsed.boundary).toBe(BOUNDARY); expect(parsed.rows).toEqual([{ id: 1 }]); expect(parsed.advisory).toBeUndefined(); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts index cf2c68b2fb..5662331342 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.e2e.test.ts @@ -1,7 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; @@ -9,14 +8,28 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase db reset (legacy)", () => { let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-db-reset-e2e-")); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); + beforeEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + workdir = yield* fs.makeTempDirectory({ prefix: "sb-db-reset-e2e-" }); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + "[db]\nport = 54322\n", + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + afterEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(workdir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); // Docker-free: the destructive remote-reset confirmation fires after the config // load and BEFORE any connection is dialed, so a piped decline exits without a @@ -26,18 +39,18 @@ describe("supabase db reset (legacy)", () => { test( "declining the remote reset prompt prints only context canceled, no --debug hint", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase( + () => + runSupabase( ["db", "reset", "--db-url", "postgresql://postgres:postgres@127.0.0.1:9999/postgres"], { entrypoint: "legacy", cwd: workdir, stdin: "n\n" }, - ); - expect(exitCode).toBe(1); - // The destructive confirmation (default No → `[y/N]`) actually rendered and - // was answered — the cancellation didn't come from some other failure path. - expect(stripAnsi(stderr)).toContain("[y/N]"); - const lines = stripAnsi(stderr).trimEnd().split("\n"); - expect(lines.at(-1)).toBe("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); - }, + ).then(({ exitCode, stderr }) => { + expect(exitCode).toBe(1); + // The destructive confirmation (default No → `[y/N]`) actually rendered and + // was answered — the cancellation didn't come from some other failure path. + expect(stripAnsi(stderr)).toContain("[y/N]"); + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + }), ); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts index 3da9f2f9e0..589242c039 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.handler.ts @@ -102,69 +102,53 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // `supabase/.env` is honored. Load the project env first and resolve both // gates against it, as `db pull` does for `yes`. const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const effectiveProjectEnv = { + ...projectEnv, + ...(yield* legacyApplyProjectEnv(projectEnv)), + }; const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnv); let linkedRefForCache: string | undefined; const body = Effect.gen(function* () { - // The project `.env` is applied to make every key visible to the WHOLE - // reset run, not just the flag-gate reads above — in particular - // `legacyGetRegistryImageUrl` / `legacyPgDeltaNpmRegistryOption` read - // `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` straight from - // `process.env` for the pg-delta catalog export below (review CLI-1958). `db push` - // (`push.handler.ts`) scopes this the same way, as the first statement of its own - // `body` — mirror that exactly so a private/air-gapped registry configured only in - // `supabase/.env` reaches the catalog export instead of silently falling back to the - // default registries. - yield* legacyApplyProjectEnv(projectEnv); const target = resolveLegacyDbTargetFlags(cliArgs.args); // Mutually-exclusive db-url/linked/local group. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyDbResetTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDbResetTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } // `--last` is an unsigned flag, so a negative value is rejected at parse // time (`Flag.integer` here accepts it). Reject it the same way rather // than silently treating it as "no --last" and resetting the full history. if (Option.isSome(flags.last) && flags.last.value < 0) { - return yield* Effect.fail( - new LegacyDbResetLastFlagError({ - message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, - }), - ); + return yield* new LegacyDbResetLastFlagError({ + message: `invalid argument "${flags.last.value}" for "--last" flag: strconv.ParseUint: parsing "${flags.last.value}": invalid syntax`, + }); } // Mutually-exclusive version/last group — alphabetical group. if (Option.isSome(flags.version) && Option.isSome(flags.last)) { - return yield* Effect.fail( - new LegacyDbResetVersionFlagsError({ - message: - "if any flags in the group [last version] are set none of the others can be; [last version] were all set", - }), - ); + return yield* new LegacyDbResetVersionFlagsError({ + message: + "if any flags in the group [last version] are set none of the others can be; [last version] were all set", + }); } // `--no-seed` conflicts with `--sql-paths`, and each `--sql-paths` value // must be non-empty. if (flags.noSeed && flags.sqlPaths.length > 0) { - return yield* Effect.fail( - new LegacyDbResetSeedFlagsError({ - message: "--no-seed cannot be used with --sql-paths", - suggestion: `Use either ${legacyAqua("--no-seed")} to skip seeding or ${legacyAqua( - "--sql-paths", - )} to override seed files, not both.`, - }), - ); + return yield* new LegacyDbResetSeedFlagsError({ + message: "--no-seed cannot be used with --sql-paths", + suggestion: `Use either ${legacyAqua("--no-seed")} to skip seeding or ${legacyAqua( + "--sql-paths", + )} to override seed files, not both.`, + }); } if (flags.sqlPaths.some((p) => p.length === 0)) { - return yield* Effect.fail( - new LegacyDbResetSeedFlagsError({ - message: "--sql-paths requires a non-empty path or glob pattern", - suggestion: `Pass a non-empty file path or glob pattern to ${legacyAqua("--sql-paths")}.`, - }), - ); + return yield* new LegacyDbResetSeedFlagsError({ + message: "--sql-paths requires a non-empty path or glob pattern", + suggestion: `Pass a non-empty file path or glob pattern to ${legacyAqua("--sql-paths")}.`, + }); } // A remote target flag + --sql-paths warns about the seed override. if ( @@ -189,11 +173,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega if (legacyParseMigrationVersion(v) === undefined) { // The bare "invalid version number" is returned unwrapped; the // `failed to parse <v>:` wrapper belongs to `migration repair` only. - return yield* Effect.fail( - new LegacyDbResetInvalidVersionError({ - message: "invalid version number", - }), - ); + return yield* new LegacyDbResetInvalidVersionError({ + message: "invalid version number", + }); } // The version is validated by globbing `supabase/migrations/<version>_*.sql` // DIRECTLY with no filtering — so a deprecated first migration (e.g. @@ -207,11 +189,9 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega (name) => legacyPathMatch(`${v}_*.sql`, path.basename(name)).matched, ); if (!found) { - return yield* Effect.fail( - new LegacyDbResetMigrationFileError({ - message: `glob supabase/migrations/${v}_*.sql: file does not exist`, - }), - ); + return yield* new LegacyDbResetMigrationFileError({ + message: `glob supabase/migrations/${v}_*.sql: file does not exist`, + }); } resolvedVersion = v; } else if (Option.isSome(flags.last) && flags.last.value > 0) { @@ -231,12 +211,10 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega // discarded on a non-linked target — see push.handler.ts's identical guard // for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyDbResetTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyDbResetTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // The project ref is loaded BEFORE the fallible linked resolution, and @@ -307,9 +285,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega false, ); if (!shouldReset) { - return yield* Effect.fail( - new LegacyDbResetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyDbResetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); @@ -341,7 +317,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega workdir, toml.schemaPaths, applyError, - projectEnv, + effectiveProjectEnv, ); } else if (toml.migrationsEnabled) { const locals = yield* legacyListLocalMigrations(fs, path, migrationsDir); @@ -373,7 +349,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega resolvedSeed.sqlPaths, workdir, ); - yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + yield* legacySeedData(session, fs, workdir, path, seeds, effectiveProjectEnv, applyError); } // Best-effort caches the migrations catalog for pg-delta right after @@ -399,7 +375,7 @@ export const legacyDbReset = Effect.fn("legacy.db.reset")(function* (flags: Lega cwd: workdir, npmVersion: Option.getOrUndefined(toml.pgDelta.npmVersion), denoVersion: toml.denoVersion, - projectEnv: toml.projectEnv, + projectEnv: { ...toml.projectEnv, ...effectiveProjectEnv }, }; yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { enabled: cacheEnabled, diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 4addec1d0e..8f8ed36336 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -1,9 +1,20 @@ -import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + PlatformError, + Sink, + Stream, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -41,12 +52,14 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { LegacyEdgeRuntimeScript, type LegacyEdgeRuntimeRunOpts, } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../../shared/legacy-local-gateway-http-client.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags, @@ -87,6 +100,56 @@ const DEFAULT_FLAGS: LegacyDbResetFlags = { last: Option.none(), }; +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingWrites = new Map< + string, + Array<{ readonly path: string; readonly contents: string }> +>(); +const pendingStandaloneWrites: Array<{ readonly path: string; readonly contents: string }> = []; + +function formatCause(cause: Cause.Cause<unknown>) { + return Formatter.formatJson(cause); +} + +function queueWrite(workdir: string, path: string, contents: string) { + const writes = pendingWrites.get(workdir) ?? []; + writes.push({ path, contents }); + pendingWrites.set(workdir, writes); +} + +function writeFileSync(path: string, contents: string) { + pendingStandaloneWrites.push({ path, contents }); +} + +function flushFixtureWrites(workdir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const writes = pendingWrites.get(workdir) ?? []; + for (const write of writes) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingWrites.delete(workdir); + for (const write of pendingStandaloneWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingStandaloneWrites.length = 0; + }); +} + +function chmodPath(path: string, mode: number) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.chmod(path, mode); + }).pipe(Effect.provide(BunServices.layer)); +} + +function prepareFixtures(workdir: string) { + return flushFixtureWrites(workdir).pipe(Effect.provide(BunServices.layer)); +} + /** * Tracks every `resolve`/`resolvePoolerFallback` invocation so tests can prove a * connection was resolved exactly once per reset — `resolve()` mints/verifies a @@ -269,14 +332,12 @@ function mockContainerCliSpawner(route: (args: ReadonlyArray<string>) => RouteRe spawned.push({ args }); if (command._tag !== "StandardCommand") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } const result = route(args); @@ -313,7 +374,7 @@ function containerNameFromCreateArgs(args: ReadonlyArray<string>): string { } function fakeContainerId(name: string): string { - return [...name] + return Array.from(name) .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) .join("") .padEnd(64, "0") @@ -446,16 +507,15 @@ function setup( // `LegacyProjectNotLinkedError` absent an explicit `--project-ref` flag, // instead of silently falling back to `opts.ref ?? LEGACY_VALID_REF`. linkedFails?: boolean; + env?: Readonly<Record<string, string | undefined>>; }, ) { if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + queueWrite(workdir, join(workdir, "supabase", "config.toml"), opts.toml); } for (const [rel, content] of Object.entries(opts.files ?? {})) { const abs = join(workdir, rel); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); + queueWrite(workdir, abs, content); } const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); @@ -483,7 +543,7 @@ function setup( const edgeRuntime = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { edgeRunCalls.push(runOpts); - registryEnvAtRunTime.push(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); + registryEnvAtRunTime.push(runOpts.projectEnvValues?.["SUPABASE_INTERNAL_IMAGE_REGISTRY"]); if (opts.catalogExportFailWith !== undefined) { return Effect.fail( new LegacyEdgeRuntimeScriptError({ message: opts.catalogExportFailWith }), @@ -492,6 +552,11 @@ function setup( return Effect.succeed({ stdout: opts.catalogStdout ?? '{"version":1}', stderr: "" }); }, }); + const providerEnv: Record<string, string> = {}; + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (value !== undefined) providerEnv[key] = value; + } + const configProvider = ConfigProvider.fromEnv({ env: providerEnv, preserveEmptyStrings: true }); const pgDeltaSslProbe = Layer.succeed(LegacyPgDeltaSslProbe, { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), @@ -502,11 +567,16 @@ function setup( conn.layer, resolver.layer, mockLegacyCliConfig({ workdir }), - BunServices.layer, + BunServices.layer.pipe( + Layer.tap((context) => flushFixtureWrites(workdir).pipe(Effect.provideContext(context))), + ), + makeLegacyViperEnvLayer(configProvider), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), child.layer, mockRuntimeInfo({ platform: "linux" }), mockProcessControl().layer, alwaysReadyHttpClientLayer, + legacyLocalGatewayHttpClientTestLayer(alwaysReadyHttpClientLayer), legacyDockerRunLayer.pipe( Layer.provide(child.layer), Layer.provide(mockProcessControl().layer), @@ -701,7 +771,7 @@ describe("legacy db reset", () => { return Effect.gen(function* () { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("is not running."); + if (Exit.isFailure(exit)) expect(formatCause(exit.cause)).toContain("is not running."); expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( false, ); @@ -721,7 +791,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + expect(formatCause(exit.cause)).toContain("failed to load config"); } expect(child.spawned.some((s) => s.args[0] === "container" && s.args[1] === "rm")).toBe( false, @@ -767,21 +837,13 @@ describe("legacy db reset", () => { toml: 'project_id = "test"\n', args: ["db", "reset", "--local"], isLocal: true, + env: { GITHUB_HEAD_REF: "feature-x" }, }); - const previous = process.env["GITHUB_HEAD_REF"]; - process.env["GITHUB_HEAD_REF"] = "feature-x"; return Effect.gen(function* () { yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("on branch "); expect(out.stderrText).toContain("feature-x"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = previous; - }), - ), - ); + }); }); it.live("emits a json result for a local reset", () => { @@ -814,7 +876,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to remove container"); + expect(formatCause(exit.cause)).toContain("failed to remove container"); } expect(telemetry.flushed).toBe(true); }); @@ -883,7 +945,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to restart supabase_storage_test"); + expect(formatCause(exit.cause)).toContain("failed to restart supabase_storage_test"); } }); }); @@ -952,7 +1014,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); + const cause = formatCause(exit.cause); expect(cause).toContain("permission denied to create database"); expect(cause).toContain("At statement: 1"); expect(cause).toContain("CREATE DATABASE postgres WITH OWNER postgres"); @@ -996,7 +1058,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to disconnect clients"); + expect(formatCause(exit.cause)).toContain("failed to disconnect clients"); } }); }); @@ -1080,7 +1142,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to count replication slots"); + expect(formatCause(exit.cause)).toContain("failed to count replication slots"); } // A single attempt — the permanent failure never retries. const countCalls = conn.queries.filter((q) => q.sql === COUNT_REPLICATION_SLOTS); @@ -1101,7 +1163,7 @@ describe("legacy db reset", () => { const exit = yield* legacyDbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("replication slots still active"); + expect(formatCause(exit.cause)).toContain("replication slots still active"); } }); }, @@ -1300,7 +1362,7 @@ describe("legacy db reset", () => { // Config loads through the established reader (`legacyCheckDbToml`), // so a malformed config aborts with `failed to load config`, same // as the other db commands (diff/dump/pull/migration). - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + expect(formatCause(exit.cause)).toContain("failed to load config"); } }); }); @@ -1309,24 +1371,16 @@ describe("legacy db reset", () => { // Regression: `enabled = "env(VAR)"` must load via env-expansion + boolean // parsing (`legacyCheckDbToml`) instead of the strict @supabase/config // loader rejecting it. - const previous = process.env["MIGRATIONS_ENABLED"]; - process.env["MIGRATIONS_ENABLED"] = "true"; const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nenabled = "env(MIGRATIONS_ENABLED)"\n', files: migrationFile("20240101000000"), confirm: [true], + env: { MIGRATIONS_ENABLED: "true" }, }); return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["MIGRATIONS_ENABLED"]; - else process.env["MIGRATIONS_ENABLED"] = previous; - }), - ), - ); + }); }); it.live("rejects mutually exclusive target flags", () => { @@ -1350,7 +1404,7 @@ describe("legacy db reset", () => { last: Option.some(1), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("[last version]"); + if (Exit.isFailure(exit)) expect(formatCause(exit.cause)).toContain("[last version]"); }); }); @@ -1385,7 +1439,7 @@ describe("legacy db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(formatCause(exit.cause)).toContain( "glob supabase/migrations/20240101000000_*.sql: file does not exist", ); } @@ -1444,7 +1498,7 @@ describe("legacy db reset", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain("context canceled"); + if (Exit.isFailure(exit)) expect(formatCause(exit.cause)).toContain("context canceled"); expect(conn.execs).toHaveLength(0); }); }); @@ -1488,9 +1542,7 @@ describe("legacy db reset", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "failed to parse config: missing private key", - ); + expect(formatCause(exit.cause)).toContain("failed to parse config: missing private key"); } // Config load failed before ResetAll → schemas were never dropped. expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); @@ -1512,9 +1564,7 @@ describe("legacy db reset", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "Missing required field in config: project_id", - ); + expect(formatCause(exit.cause)).toContain("Missing required field in config: project_id"); } expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); }); @@ -1612,7 +1662,7 @@ describe("legacy db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(formatCause(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } @@ -1729,8 +1779,6 @@ describe("legacy db reset", () => { // scoping (same-named test in `push.integration.test.ts`). Without // that scoping, this reads only real `process.env` and falls back to // the default registry instead. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; const { layer, registryEnvAtRunTime } = setup(tmp.current, { toml: 'project_id = "test"\n[experimental.pgdelta]\nenabled = true\n', files: { @@ -1742,16 +1790,7 @@ describe("legacy db reset", () => { return Effect.gen(function* () { yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); expect(registryEnvAtRunTime).toEqual(["my-mirror.example.com"]); - // The finalizer reverted it — never leaks into the surrounding process. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - ); + }); }, ); @@ -1994,7 +2033,7 @@ describe("legacy db reset", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); + const cause = formatCause(exit.cause); expect(cause).toContain("no files matched pattern: supabase/nomatch/*.sql"); // No CmdSuggestion on this failure mode — only a per-file exec failure sets one. expect(cause).not.toContain("See schema file"); @@ -2044,7 +2083,7 @@ describe("legacy db reset", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); + const cause = formatCause(exit.cause); expect(cause).toContain("syntax error at or near"); // The suggestion is `"See schema file: <Bold(fp)>"` (established output contract). expect(cause).toContain("See schema file:"); @@ -2070,20 +2109,23 @@ describe("legacy db reset", () => { experimental: true, confirm: [true], }); - chmodSync(schemaFile, 0o000); + const makeUnreadable = prepareFixtures(tmp.current).pipe( + Effect.flatMap(() => chmodPath(schemaFile, 0o000)), + ); return Effect.gen(function* () { + yield* makeUnreadable; const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); + const cause = formatCause(exit.cause); expect(cause).not.toContain("See schema file"); } // The statement was never reached, so it was never executed. expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); - }).pipe(Effect.ensuring(Effect.sync(() => chmodSync(schemaFile, 0o644)))); + }).pipe(Effect.ensuring(chmodPath(schemaFile, 0o644).pipe(Effect.ignore))); }, ); @@ -2102,22 +2144,25 @@ describe("legacy db reset", () => { experimental: true, confirm: [true], }); - chmodSync(schemasDir, 0o000); + const makeUnreadable = prepareFixtures(tmp.current).pipe( + Effect.flatMap(() => chmodPath(schemasDir, 0o000)), + ); return Effect.gen(function* () { + yield* makeUnreadable; const exit = yield* legacyDbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); + const cause = formatCause(exit.cause); expect(cause).toContain("failed to walk matched directory"); expect(cause).not.toContain("See schema file"); } // Schemas were already dropped before the failed apply step (drop-then-apply order). expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); - }).pipe(Effect.ensuring(Effect.sync(() => chmodSync(schemasDir, 0o755)))); + }).pipe(Effect.ensuring(chmodPath(schemasDir, 0o755).pipe(Effect.ignore))); }, ); @@ -2127,8 +2172,6 @@ describe("legacy db reset", () => { // The project `.env` is applied before EXPERIMENTAL is read, so a // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` reaches the // native three-conjunct gate the same way an explicit `--experimental` does. - const previous = process.env["SUPABASE_EXPERIMENTAL"]; - delete process.env["SUPABASE_EXPERIMENTAL"]; const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', files: { @@ -2144,14 +2187,7 @@ describe("legacy db reset", () => { expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); expect(out.stderrText).not.toContain("Applying migration"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = previous; - }), - ), - ); + }); }, ); @@ -2165,9 +2201,9 @@ describe("legacy db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + expect(formatCause(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); // The established suggestion, rendered as a Suggestion: line. - expect(JSON.stringify(exit.cause)).toContain("Use either"); + expect(formatCause(exit.cause)).toContain("Use either"); } }); }); @@ -2307,7 +2343,7 @@ describe("legacy db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); + expect(formatCause(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); } }); }); @@ -2322,7 +2358,7 @@ describe("legacy db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(formatCause(exit.cause)).toContain( "--sql-paths requires a non-empty path or glob pattern", ); } @@ -2339,7 +2375,7 @@ describe("legacy db reset", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const cause = JSON.stringify(exit.cause); + const cause = formatCause(exit.cause); expect(cause).toContain("invalid argument"); expect(cause).toContain("strconv.ParseUint"); } diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts index 14c07e3a07..5b7f288d3e 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.ts @@ -11,6 +11,7 @@ import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.la import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; import { legacyEdgeRuntimeScriptLayer } from "../../../shared/legacy-edge-runtime-script.layer.ts"; +import { legacyLocalGatewayHttpClientLayer } from "../../../shared/legacy-local-gateway-http-client.ts"; import { legacyPgDeltaSslProbeLayer } from "../../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; import { legacyIdentityStitchLayer } from "../../../shared/legacy-identity-stitch.ts"; @@ -102,5 +103,6 @@ export const legacyDbResetRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, + legacyLocalGatewayHttpClientLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts index 9f7fe90e68..b0474f7f1a 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.layers.unit.test.ts @@ -57,6 +57,7 @@ import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyEdgeRuntimeScript } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyDbResetRuntimeLayer } from "./reset.layers.ts"; @@ -120,6 +121,7 @@ function ambientStubs() { mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, heavyServiceStubs, + makeLegacyViperEnvLayer(), ); } @@ -130,7 +132,7 @@ describe("legacyDbResetRuntimeLayer — pg-delta service exposure (regression gu return Effect.gen(function* () { const edgeRuntime = yield* Effect.serviceOption(LegacyEdgeRuntimeScript); expect(Option.isSome(edgeRuntime)).toBe(true); - }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyDbResetRuntimeLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); @@ -140,7 +142,7 @@ describe("legacyDbResetRuntimeLayer — pg-delta service exposure (regression gu return Effect.gen(function* () { const sslProbe = yield* Effect.serviceOption(LegacyPgDeltaSslProbe); expect(Option.isSome(sslProbe)).toBe(true); - }).pipe(Effect.provide(legacyDbResetRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyDbResetRuntimeLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); }); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts index 738446318c..3023f239a3 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-random, effecttsgo/node-builtin-import -- this live test owns temporary host files and unique external database names. import { mkdir, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts index bd9af61f6a..b58719968a 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.extension-repair.unit.test.ts @@ -1,9 +1,6 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { useLegacyTempWorkdir } from "../../../../../../tests/helpers/legacy-mocks.ts"; import { legacyAppendExtensionDeclarations } from "./declarative.extension-repair.ts"; @@ -19,7 +16,9 @@ describe("legacyAppendExtensionDeclarations", () => { "pgcrypto", ]); expect(result.addedExtensions).toEqual(["pgcrypto", "uuid-ossp"]); - expect(readFileSync(join(tmp.current, "extension.sql"), "utf8")).toBe( + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(yield* fs.readFileString(path.join(tmp.current, "extension.sql"))).toBe( [ 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";', 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";', @@ -33,12 +32,14 @@ describe("legacyAppendExtensionDeclarations", () => { }); it.effect("preserves existing contents and CRLF newlines", () => { - const extensionPath = join(tmp.current, "extension.sql"); - writeFileSync(extensionPath, 'CREATE EXTENSION "pgcrypto";\r\n-- keep me'); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const extensionPath = path.join(tmp.current, "extension.sql"); + yield* fs.writeFileString(extensionPath, 'CREATE EXTENSION "pgcrypto";\r\n-- keep me'); const result = yield* legacyAppendExtensionDeclarations(tmp.current, ["pgcrypto", "pg_net"]); expect(result.addedExtensions).toEqual(["pg_net"]); - expect(readFileSync(extensionPath, "utf8")).toBe( + expect(yield* fs.readFileString(extensionPath)).toBe( 'CREATE EXTENSION "pgcrypto";\r\n-- keep me\r\n' + 'CREATE EXTENSION IF NOT EXISTS "pg_net" WITH SCHEMA "extensions";\r\n', ); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts index 52c2110d7f..ffdd4e2f4f 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.ts @@ -48,10 +48,8 @@ export const legacyRequirePgDelta = Effect.fnUntraced(function* (opts: { readonly configPath: string; }) { if (legacyIsPgDeltaEnabled(opts.experimental, opts.pgDeltaEnabled)) return; - return yield* Effect.fail( - new LegacyDeclarativeNotEnabledError({ - message: "declarative commands require --experimental flag or pg-delta enabled in config", - suggestion: legacyPgDeltaSuggestion(opts.configPath), - }), - ); + return yield* new LegacyDeclarativeNotEnabledError({ + message: "declarative commands require --experimental flag or pg-delta enabled in config", + suggestion: legacyPgDeltaSuggestion(opts.configPath), + }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts index 030c0890ea..26e12ef749 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.gate.unit.test.ts @@ -1,5 +1,5 @@ import { Cause, Effect, Exit } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { stripAnsi } from "../../../../../../tests/helpers/ansi.ts"; import { LegacyDeclarativeNotEnabledError } from "./declarative.errors.ts"; @@ -33,35 +33,39 @@ describe("legacyPgDeltaSuggestion", () => { }); describe("legacyRequirePgDelta", () => { - it("passes through when the gate is open", async () => { - const exit = await Effect.runPromiseExit( - legacyRequirePgDelta({ - experimental: true, - pgDeltaEnabled: false, - configPath: "supabase/config.toml", - }), - ); - expect(Exit.isSuccess(exit)).toBe(true); - }); - - it("fails with LegacyDeclarativeNotEnabledError when the gate is closed", async () => { - const exit = await Effect.runPromiseExit( - legacyRequirePgDelta({ - experimental: false, - pgDeltaEnabled: false, - configPath: "supabase/config.toml", - }), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = exit.cause.reasons.find(Cause.isFailReason)?.error; - expect(error).toBeInstanceOf(LegacyDeclarativeNotEnabledError); - expect(error?.message).toBe( - "declarative commands require --experimental flag or pg-delta enabled in config", + it.effect("passes through when the gate is open", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyRequirePgDelta({ + experimental: true, + pgDeltaEnabled: false, + configPath: "supabase/config.toml", + }), ); - expect(stripAnsi((error as LegacyDeclarativeNotEnabledError).suggestion)).toBe( - EXPECTED_SUGGESTION, + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); + + it.effect("fails with LegacyDeclarativeNotEnabledError when the gate is closed", () => + Effect.gen(function* () { + const exit = yield* Effect.exit( + legacyRequirePgDelta({ + experimental: false, + pgDeltaEnabled: false, + configPath: "supabase/config.toml", + }), ); - } - }); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = exit.cause.reasons.find(Cause.isFailReason)?.error; + expect(error).toBeInstanceOf(LegacyDeclarativeNotEnabledError); + expect(error?.message).toBe( + "declarative commands require --experimental flag or pg-delta enabled in config", + ); + expect(stripAnsi((error as LegacyDeclarativeNotEnabledError).suggestion)).toBe( + EXPECTED_SUGGESTION, + ); + } + }), + ); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 62c85f1f7c..b9842d8806 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -1,9 +1,6 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { mockLegacyShadowContainerCliSpawner } from "../../../../../../tests/helpers/legacy-mocks.ts"; import { alwaysReadyHttpClientLayer } from "../../../../../../tests/helpers/legacy-local-reset.ts"; @@ -49,6 +46,11 @@ import { legacyDiffDeclarativeToMigrations, legacyGenerateDeclarativeOutput, } from "./declarative.orchestrate.ts"; +import { makeLegacyViperEnvLayer } from "../../../../../shared/legacy/legacy-viper-env.ts"; + +const legacyViperEnvLayer = makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), +); function mockSeam(paths: Record<LegacyCatalogMode, string>) { const calls: Array<{ mode: LegacyCatalogMode; noCache: boolean }> = []; @@ -57,8 +59,8 @@ function mockSeam(paths: Record<LegacyCatalogMode, string>) { calls.push({ mode, noCache }); return Effect.succeed(paths[mode]); }, - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => Effect.void, + ensureLocalDatabaseStarted: Effect.void, + ensureLocalPostgresImageCurrent: Effect.void, }); return { layer, calls }; } @@ -174,21 +176,23 @@ const engineLayer = ( runtime: ReturnType<typeof mockShadowInfra>["layer"], ) => legacyPgDeltaLegacyEngineLayer.pipe( - Layer.provide(Layer.mergeAll(seam, edge, probe, output, BunServices.layer, runtime)), + Layer.provide( + Layer.mergeAll(seam, edge, probe, output, BunServices.layer, runtime, legacyViperEnvLayer), + ), ); +const withTempWorkdir = <A, E, R>( + run: (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) => Effect.Effect<A, E, R>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-decl-orch-" }); + return yield* run(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); + describe("legacyDiffDeclarativeToMigrations", () => { it.effect("loads nested SQL and its manifest in stable order for the engine", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "z.sql"), "select 'z';"); - writeFileSync(join(declDir, "nested", "a.sql"), "select 'a';"); - writeFileSync(join(declDir, "ignored.txt"), "ignored"); - writeFileSync( - join(declDir, ".pgdelta-export.json"), - JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), - ); const calls: LegacyPgDeltaDeclarativePlanInput[] = []; const engine = Layer.succeed( LegacyPgDeltaEngine, @@ -226,34 +230,40 @@ describe("legacyDiffDeclarativeToMigrations", () => { }, }), ); - return legacyDiffDeclarativeToMigrations( - { ...ctx(dir, declDir), debug: true, noCache: true, strictCoverage: true }, - toml, - setupInputs, - ).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(calls[0]?.files).toEqual([ - { name: "nested/a.sql", sql: "select 'a';" }, - { name: "z.sql", sql: "select 'z';" }, - ]); - expect(calls[0]?.manifest).toEqual({ redactSecrets: true, scope: "database" }); - expect(calls[0]?.debug).toBe(true); - expect(calls[0]?.noCache).toBe(true); - expect(calls[0]?.strictCoverage).toBe(true); - expect(result.manifestPresent).toBe(true); - expect(result.dropWarnings).toEqual([ - "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;", - ]); - expect(result.removals).toEqual({ - extensions: ["pgcrypto"], - extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], - }); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(Layer.mergeAll(engine, BunServices.layer)), - ); + return withTempWorkdir((fs, path, dir) => { + const declDir = path.join(dir, "supabase", "database"); + return Effect.gen(function* () { + yield* fs.makeDirectory(path.join(declDir, "nested"), { recursive: true }); + yield* fs.writeFileString(path.join(declDir, "z.sql"), "select 'z';"); + yield* fs.writeFileString(path.join(declDir, "nested", "a.sql"), "select 'a';"); + yield* fs.writeFileString(path.join(declDir, "ignored.txt"), "ignored"); + yield* fs.writeFileString( + path.join(declDir, ".pgdelta-export.json"), + '{"formatVersion":1,"redactSecrets":true,"scope":"database"}', + ); + const result = yield* legacyDiffDeclarativeToMigrations( + { ...ctx(dir, declDir), debug: true, noCache: true, strictCoverage: true }, + toml, + setupInputs, + ); + expect(calls[0]?.files).toEqual([ + { name: "nested/a.sql", sql: "select 'a';" }, + { name: "z.sql", sql: "select 'z';" }, + ]); + expect(calls[0]?.manifest).toEqual({ redactSecrets: true, scope: "database" }); + expect(calls[0]?.debug).toBe(true); + expect(calls[0]?.noCache).toBe(true); + expect(calls[0]?.strictCoverage).toBe(true); + expect(result.manifestPresent).toBe(true); + expect(result.dropWarnings).toEqual([ + "ALTER TABLE public.accounts ALTER COLUMN email TYPE text;", + ]); + expect(result.removals).toEqual({ + extensions: ["pgcrypto"], + extensionIntents: [{ extension: "pg_cron", intentKind: "job", key: "refresh metrics" }], + }); + }).pipe(Effect.provide(Layer.mergeAll(engine, BunServices.layer))); + }); }); // The legacy engine's `planDeclarativeSchema` never looks at `input.manifest`, so @@ -284,53 +294,58 @@ describe("legacyDiffDeclarativeToMigrations", () => { }), ); - const withCorruptManifest = () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "create table public.accounts();"); - writeFileSync(join(declDir, ".pgdelta-export.json"), "{ not json at all"); - return { dir, declDir }; + const withCorruptManifest = (fs: FileSystem.FileSystem, path: Path.Path, dir: string) => { + const declDir = path.join(dir, "supabase", "database"); + return Effect.gen(function* () { + yield* fs.makeDirectory(declDir, { recursive: true }); + yield* fs.writeFileString( + path.join(declDir, "public.sql"), + "create table public.accounts();", + ); + yield* fs.writeFileString(path.join(declDir, ".pgdelta-export.json"), "{ not json at all"); + return declDir; + }); }; it.effect("ignores a corrupt export manifest under the legacy engine opt-out", () => { - const { dir, declDir } = withCorruptManifest(); const calls: LegacyPgDeltaDeclarativePlanInput[] = []; - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(calls[0]?.files).toEqual([ - { name: "public.sql", sql: "create table public.accounts();" }, - ]); - expect(calls[0]?.manifest).toBeUndefined(); - expect(result.manifestPresent).toBe(false); - expect(result.diffSQL).toBe("create table public.accounts();"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(Layer.mergeAll(stubEngine("legacy", calls), BunServices.layer)), + return withTempWorkdir((fs, path, dir) => + Effect.gen(function* () { + const declDir = yield* withCorruptManifest(fs, path, dir); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); + expect(calls[0]?.files).toEqual([ + { name: "public.sql", sql: "create table public.accounts();" }, + ]); + expect(calls[0]?.manifest).toBeUndefined(); + expect(result.manifestPresent).toBe(false); + expect(result.diffSQL).toBe("create table public.accounts();"); + }).pipe(Effect.provide(Layer.mergeAll(stubEngine("legacy", calls), BunServices.layer))), ); }); it.effect("still rejects a corrupt export manifest under the next engine", () => { - const { dir, declDir } = withCorruptManifest(); const calls: LegacyPgDeltaDeclarativePlanInput[] = []; - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = exit.cause.reasons.find(Cause.isFailReason)?.error; - expect(String((error as { message?: string } | undefined)?.message)).toContain( - "malformed export manifest", - ); - } - expect(calls).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(Layer.mergeAll(stubEngine("next", calls), BunServices.layer)), + return withTempWorkdir((fs, path, dir) => + Effect.gen(function* () { + const declDir = yield* withCorruptManifest(fs, path, dir); + const exit = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = exit.cause.reasons.find(Cause.isFailReason)?.error; + expect(String((error as { message?: string } | undefined)?.message)).toContain( + "malformed export manifest", + ); + } + expect(calls).toEqual([]); + }).pipe(Effect.provide(Layer.mergeAll(stubEngine("next", calls), BunServices.layer))), ); }); }); @@ -392,65 +407,61 @@ const toml: LegacyDbTomlValues = { describe("legacyDiffDeclarativeToMigrations", () => { it.effect( "resolves the migrations catalog natively and diffs it against the seam-provisioned declarative catalog", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\nDROP TABLE z;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - // "declarative" still resolves via the seam; "migrations" no longer does - // (it resolves natively, provisioning its shadow the same way `db diff`/ - // `db pull` do — CLI-1956). - expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // No local migrations in the fresh temp dir → the zero-migrations branch - // writes (and returns) the platform-baseline catalog, workdir-relative. - expect(result.sourceRef).toMatch( - /^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/, - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(result.targetRef).toBe("supabase/.temp/pgdelta/decl.json"); - expect(result.diffSQL).toContain("ALTER TABLE x"); - expect(result.dropWarnings).toEqual(["DROP TABLE z"]); - // The edge-runtime diff received the migrations ref (workdir-relative, - // mapped to /workspace) and the seam's declarative ref as SOURCE/TARGET. - const diffCall = edge.calls.find((c) => c.script.includes("renderPlanFiles")); - expect(diffCall?.env["SOURCE"]).toBe(`/workspace/${result.sourceRef}`); - expect(diffCall?.env["TARGET"]).toBe("/workspace/supabase/.temp/pgdelta/decl.json"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, + () => + withTempWorkdir((fs, path, dir) => { + const declDir = path.join(dir, "supabase", "database"); + const seam = mockSeam({ + declarative: "supabase/.temp/pgdelta/decl.json", + baseline: "supabase/.temp/pgdelta/base.json", + }); + const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\nDROP TABLE z;\n"); + const out = mockOutput(); + const shadow = mockShadowInfra(); + return Effect.gen(function* () { + yield* fs.makeDirectory(declDir, { recursive: true }); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ).pipe( + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + shadow.layer, + legacyViperEnvLayer, + ), + ), + ); + // "declarative" still resolves via the seam; "migrations" no longer does + // (it resolves natively, provisioning its shadow the same way `db diff`/ + // `db pull` do — CLI-1956). + expect(seam.calls.map((c) => c.mode)).toEqual(["declarative"]); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + // No local migrations in the fresh temp dir → the zero-migrations branch + // writes (and returns) the platform-baseline catalog, workdir-relative. + expect(result.sourceRef).toMatch( + /^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/, + ); + expect(yield* fs.readFileString(path.join(dir, result.sourceRef))).toBe('{"schemas":[]}'); + expect(result.targetRef).toBe("supabase/.temp/pgdelta/decl.json"); + expect(result.diffSQL).toContain("ALTER TABLE x"); + expect(result.dropWarnings).toEqual(["DROP TABLE z"]); + const diffCall = edge.calls.find((c) => c.script.includes("renderPlanFiles")); + expect(diffCall?.env["SOURCE"]).toBe(`/workspace/${result.sourceRef}`); + expect(diffCall?.env["TARGET"]).toBe("/workspace/supabase/.temp/pgdelta/decl.json"); + }); + }), ); // `--strict-coverage` is enforced entirely by the next engine's diagnostic report; // the legacy engine has no coverage diagnostics, so the flag silently did nothing // under `SUPABASE_USE_PG_DELTA_NEXT=false`. It must say so instead. - const runWithStrictCoverageOnLegacyEngine = () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); + const runWithStrictCoverageOnLegacyEngine = (dir: string, declDir: string) => { const seam = mockSeam({ declarative: "supabase/.temp/pgdelta/decl.json", baseline: "supabase/.temp/pgdelta/base.json", @@ -482,301 +493,462 @@ describe("legacyDiffDeclarativeToMigrations", () => { }; it.effect("warns that --strict-coverage does nothing on the legacy engine", () => { - const { dir, out, effect } = runWithStrictCoverageOnLegacyEngine(); - return effect.pipe( - Effect.tap(() => - Effect.sync(() => { - expect(out.stderrText).toContain( - '"--strict-coverage" has no effect with the legacy pg-delta engine.', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return withTempWorkdir((fs, path, dir) => { + const declDir = path.join(dir, "supabase", "database"); + return Effect.gen(function* () { + yield* fs.makeDirectory(declDir, { recursive: true }); + const { out, effect } = runWithStrictCoverageOnLegacyEngine(dir, declDir); + yield* effect; + expect(out.stderrText).toContain( + '"--strict-coverage" has no effect with the legacy pg-delta engine.', + ); + }); + }); }); it.effect( "reuses an already-warmed platform-baseline catalog without provisioning a shadow", - () => { - // A baseline catalog pre-warmed by a prior generate/sync run (same setup - // inputs, still zero local migrations) must be reused as-is — this is the - // whole point of the zero-migrations special case in - // `legacyGetMigrationsCatalogRef` (mirrors Go's `getMigrationsCatalogRef`, - // `declarative.go:380-392`). - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const baselineKey = legacyBaselineCatalogKey(setupInputs); - const baselinePath = join(tempDir, legacyBaselineCatalogFileName(baselineKey)); - writeFileSync(baselinePath, '{"warmed":true}'); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(shadow.spawned).toEqual([]); - expect(result.sourceRef).toBe( - join("supabase", ".temp", "pgdelta", `catalog-baseline-${baselineKey}.json`), - ); - expect(readFileSync(baselinePath, "utf8")).toBe('{"warmed":true}'); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, + () => + withTempWorkdir((fs, path, dir) => + Effect.gen(function* () { + // A baseline catalog pre-warmed by a prior generate/sync run (same setup + // inputs, still zero local migrations) must be reused as-is — this is the + // whole point of the zero-migrations special case in + // `legacyGetMigrationsCatalogRef` (mirrors Go's `getMigrationsCatalogRef`, + // `declarative.go:380-392`). + const declDir = path.join(dir, "supabase", "database"); + const tempDir = path.join(dir, "supabase", ".temp", "pgdelta"); + yield* fs.makeDirectory(declDir, { recursive: true }); + yield* fs.makeDirectory(tempDir, { recursive: true }); + const baselineKey = legacyBaselineCatalogKey(setupInputs); + const baselinePath = path.join(tempDir, legacyBaselineCatalogFileName(baselineKey)); + yield* fs.writeFileString(baselinePath, '{"warmed":true}'); + const seam = mockSeam({ + declarative: "supabase/.temp/pgdelta/decl.json", + baseline: "supabase/.temp/pgdelta/base.json", + }); + const edge = mockEdge("ALTER TABLE x;\n"); + const out = mockOutput(); + const shadow = mockShadowInfra(); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ).pipe( + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + legacyViperEnvLayer, + ), + ), + ); + expect(shadow.spawned).toEqual([]); + expect(result.sourceRef).toBe( + path.join("supabase", ".temp", "pgdelta", `catalog-baseline-${baselineKey}.json`), + ); + expect(yield* fs.readFileString(baselinePath)).toBe('{"warmed":true}'); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "fails when the zero-migrations baseline cache probe itself fails, before any shadow work", - () => { - // A probe failure that isn't not-found (permissions, I/O under `.temp/pgdelta`) must - // propagate — matching Go's `getMigrationsCatalogRef` returning the `afero.Exists` - // error immediately — instead of being converted into a cache miss that provisions a - // Docker shadow and only surfaces the filesystem problem at the eventual write to the - // same location (codex review, PR #6162). - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const baselineFileName = legacyBaselineCatalogFileName(legacyBaselineCatalogKey(setupInputs)); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - // Wraps the real Bun `FileSystem` so only the baseline probe fails, with a genuine - // `PlatformError` (same construction as the cache unit tests' failing-fs fakes). - // Merged LAST so it overrides `BunServices.layer`'s own `FileSystem`. - const failingFsLayer = Layer.effect( - FileSystem.FileSystem, + () => + withTempWorkdir((fs, path, dir) => Effect.gen(function* () { - const real = yield* FileSystem.FileSystem; - const err = yield* real.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); - const failing: FileSystem.FileSystem = { - ...real, - exists: (p) => (p.endsWith(baselineFileName) ? Effect.fail(err) : real.exists(p)), - }; - return failing; - }), - ).pipe(Layer.provide(BunServices.layer)); - return legacyDiffDeclarativeToMigrations(ctx(dir, declDir), toml, setupInputs).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - // The whole point: the failure surfaces BEFORE any Docker side effect. - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - BunServices.layer, - seam.layer, - edge.layer, - probe, - out.layer, - shadow.layer, - legacyPgDeltaLegacyEngineLayer.pipe( - Layer.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - BunServices.layer, - shadow.layer, - failingFsLayer, + // A probe failure that isn't not-found (permissions, I/O under `.temp/pgdelta`) must + // propagate — matching Go's `getMigrationsCatalogRef` returning the `afero.Exists` + // error immediately — instead of being converted into a cache miss that provisions a + // Docker shadow and only surfaces the filesystem problem at the eventual write to the + // same location (codex review, PR #6162). + const declDir = path.join(dir, "supabase", "database"); + yield* fs.makeDirectory(declDir, { recursive: true }); + const baselineFileName = legacyBaselineCatalogFileName( + legacyBaselineCatalogKey(setupInputs), + ); + const seam = mockSeam({ + declarative: "supabase/.temp/pgdelta/decl.json", + baseline: "supabase/.temp/pgdelta/base.json", + }); + const edge = mockEdge("ALTER TABLE x;\n"); + const out = mockOutput(); + const shadow = mockShadowInfra(); + // Wraps the real Bun `FileSystem` so only the baseline probe fails, with a genuine + // `PlatformError` (same construction as the cache unit tests' failing-fs fakes). + // Merged LAST so it overrides `BunServices.layer`'s own `FileSystem`. + const failingFsLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + const err = yield* real + .readDirectory(path.join(dir, "does-not-exist")) + .pipe(Effect.flip); + const failing: FileSystem.FileSystem = { + ...real, + exists: (p) => (p.endsWith(baselineFileName) ? Effect.fail(err) : real.exists(p)), + }; + return failing; + }), + ).pipe(Layer.provide(BunServices.layer)); + const exit = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ).pipe( + Effect.exit, + Effect.provide( + Layer.mergeAll( + BunServices.layer, + seam.layer, + edge.layer, + probe, + out.layer, + shadow.layer, + legacyPgDeltaLegacyEngineLayer.pipe( + Layer.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + BunServices.layer, + shadow.layer, + failingFsLayer, + legacyViperEnvLayer, + ), + ), ), + failingFsLayer, + legacyViperEnvLayer, ), ), - failingFsLayer, - ), - ), - ); - }, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(shadow.spawned).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "with local migrations present and cache enabled, provisions a shadow and caches the resulting catalog", - () => { - // The dominant real-world code path (a project WITH local migrations, cache - // enabled) — `legacyGetMigrationsCatalogRef`'s cache-miss/non-zero-migrations - // branch (declarative.go:393-430) — was previously never exercised by any - // test; every other test here uses a fresh temp dir with zero migrations. - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const result = yield* legacyDiffDeclarativeToMigrations( - ctx(dir, declDir), - toml, - setupInputs, - ); - expect(result.sourceRef).toMatch( - new RegExp( - `^supabase[/\\\\]\\.temp[/\\\\]pgdelta[/\\\\]catalog-local-migrations-${key}-\\d+\\.json$`, + () => + withTempWorkdir((fs, path, dir) => { + const seam = mockSeam({ + declarative: "supabase/.temp/pgdelta/decl.json", + baseline: "supabase/.temp/pgdelta/base.json", + }); + const edge = mockEdge("ALTER TABLE x ADD COLUMN y int;\n"); + const out = mockOutput(); + const shadow = mockShadowInfra(); + return Effect.gen(function* () { + // The dominant real-world code path (a project WITH local migrations, cache + // enabled) — `legacyGetMigrationsCatalogRef`'s cache-miss/non-zero-migrations + // branch (declarative.go:393-430) — was previously never exercised by any + // test; every other test here uses a fresh temp dir with zero migrations. + const declDir = path.join(dir, "supabase", "database"); + yield* fs.makeDirectory(declDir, { recursive: true }); + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_init.sql"), + "create table a();\n", + ); + const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); + const key = legacyMigrationsCatalogCacheKey( + legacySetupInputsToken(setupInputs), + migrationsHash, + ); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); + expect(result.sourceRef).toMatch( + new RegExp( + `^supabase[/\\\\]\\.temp[/\\\\]pgdelta[/\\\\]catalog-local-migrations-${key}-\\d+\\.json$`, + ), + ); + expect(yield* fs.readFileString(path.join(dir, result.sourceRef))).toBe('{"schemas":[]}'); + expect(out.stderrText).toContain("Creating shadow database...\n"); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe( + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), ), ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }, + }), ); it.effect( "reuses an already-cached migrations catalog for local migrations without provisioning a new shadow", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const edge = mockEdge("ALTER TABLE x;\n"); + () => + withTempWorkdir((fs, path, dir) => { + const seam = mockSeam({ + declarative: "supabase/.temp/pgdelta/decl.json", + baseline: "supabase/.temp/pgdelta/base.json", + }); + const edge = mockEdge("ALTER TABLE x;\n"); + const out = mockOutput(); + const shadow = mockShadowInfra(); + return Effect.gen(function* () { + const declDir = path.join(dir, "supabase", "database"); + yield* fs.makeDirectory(declDir, { recursive: true }); + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_init.sql"), + "create table a();\n", + ); + const tempDir = path.join(dir, "supabase", ".temp", "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); + const key = legacyMigrationsCatalogCacheKey( + legacySetupInputsToken(setupInputs), + migrationsHash, + ); + const cachedPath = path.join( + tempDir, + legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), + ); + yield* fs.writeFileString(cachedPath, '{"cached":true}'); + const result = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, declDir), + toml, + setupInputs, + ); + expect(result.sourceRef).toBe(path.relative(dir, cachedPath)); + expect(yield* fs.readFileString(cachedPath)).toBe('{"cached":true}'); + expect(shadow.spawned).toEqual([]); + }).pipe( + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), + ), + ); + }), + ); + + it.effect( + "--no-cache ignores an already-cached migrations catalog, provisions a fresh shadow, and writes catalog-nocache-migrations.json", + () => + withTempWorkdir((fs, path, dir) => { + const seam = mockSeam({ + declarative: "supabase/.temp/pgdelta/decl.json", + baseline: "supabase/.temp/pgdelta/base.json", + }); + const edge = mockEdge("ALTER TABLE x;\n"); + const out = mockOutput(); + const shadow = mockShadowInfra(); + return Effect.gen(function* () { + const declDir = path.join(dir, "supabase", "database"); + yield* fs.makeDirectory(declDir, { recursive: true }); + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101000000_init.sql"), + "create table a();\n", + ); + const tempDir = path.join(dir, "supabase", ".temp", "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + // Pre-warm the cache entry that a cache-enabled run would hit, proving + // --no-cache really skips the lookup rather than merely never having + // written that entry. + const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); + const key = legacyMigrationsCatalogCacheKey( + legacySetupInputsToken(setupInputs), + migrationsHash, + ); + const cachedPath = path.join( + tempDir, + legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), + ); + yield* fs.writeFileString(cachedPath, '{"cached":true}'); + const result = yield* legacyDiffDeclarativeToMigrations( + { ...ctx(dir, declDir), noCache: true }, + toml, + setupInputs, + ); + expect(result.sourceRef).toBe( + path.join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), + ); + expect(yield* fs.readFileString(path.join(dir, result.sourceRef))).toBe('{"schemas":[]}'); + expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + }).pipe( + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + BunServices.layer, + shadow.layer, + ), + ), + ); + }), + ); + it.effect("fails when the declarative dir is absent", () => + withTempWorkdir((fs, path, dir) => { + const seam = mockSeam({ declarative: "d", baseline: "b" }); + const edge = mockEdge(""); const out = mockOutput(); const shadow = mockShadowInfra(); return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const cachedPath = join( - tempDir, - legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), - ); - writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations( - ctx(dir, declDir), + const exit = yield* legacyDiffDeclarativeToMigrations( + ctx(dir, path.join(dir, "missing")), toml, setupInputs, + ).pipe( + Effect.exit, + Effect.provide( + Layer.mergeAll( + seam.layer, + edge.layer, + probe, + out.layer, + engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), + shadow.layer, + legacyViperEnvLayer, + ), + ), ); - expect(result.sourceRef).toBe(path.relative(dir, cachedPath)); - expect(readFileSync(cachedPath, "utf8")).toBe('{"cached":true}'); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = exit.cause.reasons.find(Cause.isFailReason)?.error; + expect((error as { message: string }).message).toContain( + "No declarative schema directory found", + ); + } + expect(seam.calls).toEqual([]); expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }).pipe( + }); + }), + ); +}); + +describe("legacyGenerateDeclarativeOutput", () => { + it.effect("propagates debug, no-cache, and strict coverage to the selected engine", () => + withTempWorkdir((_fs, path, dir) => { + const calls: Array<{ + readonly debug: boolean; + readonly noCache: boolean; + readonly sourceRef: string | undefined; + readonly strictCoverage: boolean; + }> = []; + const engine = Layer.succeed( + LegacyPgDeltaEngine, + LegacyPgDeltaEngine.of({ + implementation: "next", + diffExplicit: () => Effect.die("diffExplicit not used"), + diffDatabase: () => Effect.die("diffDatabase not used"), + exportDeclarativeSchema: (input) => { + calls.push({ + debug: input.debug, + noCache: input.noCache, + sourceRef: input.source?.ref, + strictCoverage: input.strictCoverage, + }); + return Effect.succeed({ files: [] }); + }, + planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used"), + }), + ); + const shadow = mockShadowInfra(); + const out = mockOutput(); + return legacyGenerateDeclarativeOutput( + { + ...ctx(dir, path.join(dir, "supabase", "database")), + debug: true, + noCache: true, + strictCoverage: true, + }, + toml, + { + kind: "database", + ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(calls).toEqual([ + { + debug: true, + noCache: true, + sourceRef: undefined, + strictCoverage: true, + }, + ]); + expect(shadow.spawned).toEqual([]); + }), + ), Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), + Layer.mergeAll(engine, out.layer, BunServices.layer, shadow.layer, legacyViperEnvLayer), ), ); - }, + }), ); - it.effect( - "--no-cache ignores an already-cached migrations catalog, provisions a fresh shadow, and writes catalog-nocache-migrations.json", - () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101000000_init.sql"), "create table a();\n"); - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - mkdirSync(tempDir, { recursive: true }); + it.effect("diffs a native raw shadow against the live DB and returns files", () => + withTempWorkdir((_fs, path, dir) => { const seam = mockSeam({ - declarative: "supabase/.temp/pgdelta/decl.json", + declarative: "d", baseline: "supabase/.temp/pgdelta/base.json", }); - const edge = mockEdge("ALTER TABLE x;\n"); + const payload = { + version: 1, + mode: "declarative", + files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], + }; + const edge = mockEdge(JSON.stringify(payload)); const out = mockOutput(); const shadow = mockShadowInfra(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // Pre-warm the cache entry that a cache-enabled run would hit, proving - // --no-cache really skips the lookup rather than merely never having - // written that entry. - const migrationsHash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); - const key = legacyMigrationsCatalogCacheKey( - legacySetupInputsToken(setupInputs), - migrationsHash, - ); - const cachedPath = join( - tempDir, - legacyMigrationCatalogFileName("local", key, 1_700_000_000_000), - ); - writeFileSync(cachedPath, '{"cached":true}'); - const result = yield* legacyDiffDeclarativeToMigrations( - { ...ctx(dir, declDir), noCache: true }, - toml, - setupInputs, - ); - expect(result.sourceRef).toBe( - join("supabase", ".temp", "pgdelta", "catalog-nocache-migrations.json"), - ); - expect(readFileSync(join(dir, result.sourceRef), "utf8")).toBe('{"schemas":[]}'); - expect(shadow.spawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }).pipe( + return legacyGenerateDeclarativeOutput( + ctx(dir, path.join(dir, "supabase", "database")), + toml, + { + kind: "database", + ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", + connectOptions: { isLocal: true, dnsResolver: "native" }, + }, + ).pipe( + Effect.tap((output) => + Effect.sync(() => { + expect(seam.calls).toEqual([]); + expect(output.files[0]?.name).toBe("public.sql"); + expect(edge.calls[0]!.env["SOURCE"]).toBe( + "postgresql://postgres:postgres@127.0.0.1:54320/postgres?connect_timeout=10", + ); + expect(edge.calls[0]!.env["TARGET"]).toBe( + "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", + ); + expect(shadow.spawned.filter((call) => call.args[0] === "create")).toHaveLength(1); + expect(shadow.spawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); + }), + ), Effect.provide( Layer.mergeAll( seam.layer, @@ -786,158 +958,10 @@ describe("legacyDiffDeclarativeToMigrations", () => { engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), BunServices.layer, shadow.layer, + legacyViperEnvLayer, ), ), ); - }, + }), ); - it.effect("fails when the declarative dir is absent", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-orch-")); - const seam = mockSeam({ declarative: "d", baseline: "b" }); - const edge = mockEdge(""); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyDiffDeclarativeToMigrations( - ctx(dir, join(dir, "missing")), - toml, - setupInputs, - ).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = exit.cause.reasons.find(Cause.isFailReason)?.error; - expect((error as { message: string }).message).toContain( - "No declarative schema directory found", - ); - } - expect(seam.calls).toEqual([]); - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }); -}); - -describe("legacyGenerateDeclarativeOutput", () => { - it.effect("propagates debug, no-cache, and strict coverage to the selected engine", () => { - const calls: Array<{ - readonly debug: boolean; - readonly noCache: boolean; - readonly sourceRef: string | undefined; - readonly strictCoverage: boolean; - }> = []; - const engine = Layer.succeed( - LegacyPgDeltaEngine, - LegacyPgDeltaEngine.of({ - implementation: "next", - diffExplicit: () => Effect.die("diffExplicit not used"), - diffDatabase: () => Effect.die("diffDatabase not used"), - exportDeclarativeSchema: (input) => { - calls.push({ - debug: input.debug, - noCache: input.noCache, - sourceRef: input.source?.ref, - strictCoverage: input.strictCoverage, - }); - return Effect.succeed({ files: [] }); - }, - planDeclarativeSchema: () => Effect.die("planDeclarativeSchema not used"), - }), - ); - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); - const shadow = mockShadowInfra(); - const out = mockOutput(); - return legacyGenerateDeclarativeOutput( - { - ...ctx(dir, join(dir, "supabase", "database")), - debug: true, - noCache: true, - strictCoverage: true, - }, - toml, - { - kind: "database", - ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(calls).toEqual([ - { - debug: true, - noCache: true, - sourceRef: undefined, - strictCoverage: true, - }, - ]); - expect(shadow.spawned).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(Layer.mergeAll(engine, out.layer, BunServices.layer, shadow.layer)), - ); - }); - - it.effect("diffs a native raw shadow against the live DB and returns files", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-decl-export-")); - const seam = mockSeam({ - declarative: "d", - baseline: "supabase/.temp/pgdelta/base.json", - }); - const payload = { - version: 1, - mode: "declarative", - files: [{ path: "public.sql", order: 0, statements: 1, sql: "create table a();" }], - }; - const edge = mockEdge(JSON.stringify(payload)); - const out = mockOutput(); - const shadow = mockShadowInfra(); - return legacyGenerateDeclarativeOutput(ctx(dir, join(dir, "supabase", "database")), toml, { - kind: "database", - ref: "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - connectOptions: { isLocal: true, dnsResolver: "native" }, - }).pipe( - Effect.tap((output) => - Effect.sync(() => { - expect(seam.calls).toEqual([]); - expect(output.files[0]?.name).toBe("public.sql"); - expect(edge.calls[0]!.env["SOURCE"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54320/postgres?connect_timeout=10", - ); - expect(edge.calls[0]!.env["TARGET"]).toBe( - "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10", - ); - expect(shadow.spawned.filter((call) => call.args[0] === "create")).toHaveLength(1); - expect(shadow.spawned.filter((call) => call.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide( - Layer.mergeAll( - seam.layer, - edge.layer, - probe, - out.layer, - engineLayer(seam.layer, edge.layer, out.layer, shadow.layer), - BunServices.layer, - shadow.layer, - ), - ), - ); - }); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts index d065b1bf5a..58e46a6440 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.ts @@ -103,10 +103,8 @@ export const legacyDiffDeclarativeToMigrations = Effect.fnUntraced(function* ( const engine = yield* LegacyPgDeltaEngine; const exists = yield* fs.exists(run.declarativeDir).pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return yield* Effect.fail( - declarativeError( - "No declarative schema directory found. Run supabase db schema declarative generate first.", - ), + return yield* declarativeError( + "No declarative schema directory found. Run supabase db schema declarative generate first.", ); } const files = yield* LegacyLoadPgDeltaSqlFiles(fs, path, run.declarativeDir).pipe( diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts index 03a9fdb717..595a27108e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.smart-target.ts @@ -8,14 +8,20 @@ import { legacyPromptYesNo } from "../../../../../shared/legacy/legacy-prompt-ye import { Output } from "../../../../../shared/output/output.service.ts"; import { legacyResetLocalDatabase } from "../../../../shared/db-bootstrap/reset-local-database.ts"; import { PROJECT_REF_PATTERN } from "../../../../config/legacy-project-ref.service.ts"; +import { RuntimeInfo } from "../../../../../shared/runtime/runtime-info.service.ts"; +import { legacyMakeDbConfigParseRuntime } from "../../../../shared/legacy-db-config.layer.ts"; import { LegacyDbConfigResolver } from "../../../../shared/legacy-db-config.service.ts"; import { legacyLoadProjectEnv } from "../../../../shared/legacy-db-config.toml-read.ts"; import { + LEGACY_PARSE_ENV_NAMES, + legacyConnectionStringFilePaths, + legacyLayeredParseEnv, parseLegacyConnectionString, redactLegacyConnectionString, } from "../../../../shared/legacy-db-config.parse.ts"; import { legacyGetHostname } from "../../../../shared/legacy-hostname.ts"; import { legacyToPostgresURL } from "../../../../shared/legacy-postgres-url.ts"; +import { LegacyViperEnv } from "../../../../../shared/legacy/legacy-viper-env.ts"; import type { LegacyPgDeltaDatabaseEndpoint } from "../../shared/legacy-pgdelta-engine.service.ts"; import { LegacyDeclarativeApplyError, @@ -48,29 +54,29 @@ export interface LegacySmartTargetFlags { readonly reset: boolean; } -const legacyLocalConnection = (local: LegacyLocalConn) => ({ +const legacyLocalConnection = (local: LegacyLocalConn, host: string) => ({ // Go derives the local host from `utils.Config.Hostname` (`GetHostname()`: // SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1), not a hardcoded // loopback (`apps/cli-go/internal/utils/misc.go:298-312`). - host: legacyGetHostname(), + host, port: local.port, user: "postgres", password: local.password, database: "postgres", }); -export const legacyLocalEndpoint = ( +export const legacyLocalEndpoint = Effect.fnUntraced(function* ( local: LegacyLocalConn, dnsResolver: "native" | "https", -): LegacyPgDeltaDatabaseEndpoint => { - const connection = legacyLocalConnection(local); +) { + const connection = legacyLocalConnection(local, yield* legacyGetHostname); return { kind: "database", ref: legacyToPostgresURL(connection), connection, connectOptions: { isLocal: true, dnsResolver }, - }; -}; + } satisfies LegacyPgDeltaDatabaseEndpoint; +}); /** Resolves a remote target without discarding TLS and connection options. */ export const legacyResolveRemoteEndpoint = Effect.fnUntraced(function* ( @@ -113,8 +119,8 @@ export const legacyResolveSmartTargetEndpoint = Effect.fnUntraced(function* ( // No migrations → generate from local. Go runs ensureLocalDatabaseStarted first // (db_schema_declarative.go:291), starting a stopped stack. yield* beforeLocalTarget; - yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); - return legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); + yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted; + return yield* legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); } const output = yield* Output; @@ -151,26 +157,35 @@ export const legacyResolveSmartTargetEndpoint = Effect.fnUntraced(function* ( if (choice === "custom") { const dbURL = yield* output.promptText("Enter database URL: "); if (dbURL.trim().length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeInvalidDbUrlError({ message: "database URL cannot be empty" }), - ); + return yield* new LegacyDeclarativeInvalidDbUrlError({ + message: "database URL cannot be empty", + }); + } + // Resolve the interactive connection string with libpq-compatible environment, + // service-file, pgpass, and host/user defaults. The parser is pure; filesystem + // and account defaults are materialized at this Effect composition boundary. + // Shell values take precedence over project values, and malformed input is + // reported without echoing credentials. + const env = yield* LegacyViperEnv; + const shellValues = yield* Effect.forEach(LEGACY_PARSE_ENV_NAMES, (name) => env.get(name)); + const shellEnv: Record<string, string> = {}; + for (const [index, value] of shellValues.entries()) { + const name = LEGACY_PARSE_ENV_NAMES[index]; + if (name !== undefined && Option.isSome(value)) shellEnv[name] = value.value; } - // Go parses the entry with pgconn.ParseConfig then feeds pg-delta a normalized - // ToPostgresURL (`apps/cli-go/cmd/db_schema_declarative.go:283-287`, deleted - // in CLI-1970; last present at commit 7b469f5b3). Layer the - // project env (loaded once above) under the shell env like the --db-url path so - // libpq PG* fallbacks resolve, and reject malformed input with Go's "failed to - // parse connection string" error (password redacted, CWE-209). - const conn = parseLegacyConnectionString( - dbURL, - (name) => process.env[name] ?? projectEnv[name], + const parseEnv = legacyLayeredParseEnv(projectEnv, shellEnv); + const parseRuntime = yield* legacyMakeDbConfigParseRuntime( + fs, + path, + yield* RuntimeInfo, + parseEnv, + legacyConnectionStringFilePaths(dbURL), ); + const conn = parseLegacyConnectionString(dbURL, parseEnv, parseRuntime); if (conn === undefined) { - return yield* Effect.fail( - new LegacyDeclarativeInvalidDbUrlError({ - message: `failed to parse connection string: ${redactLegacyConnectionString(dbURL)}`, - }), - ); + return yield* new LegacyDeclarativeInvalidDbUrlError({ + message: `failed to parse connection string: ${redactLegacyConnectionString(dbURL)}`, + }); } return { kind: "database", @@ -183,7 +198,7 @@ export const legacyResolveSmartTargetEndpoint = Effect.fnUntraced(function* ( // "Local database" choice: Go runs ensureLocalDatabaseStarted before the reset // prompt (db_schema_declarative.go:249), starting a stopped stack. yield* beforeLocalTarget; - yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted(); + yield* (yield* LegacyDeclarativeSeam).ensureLocalDatabaseStarted; let shouldReset = flags.reset; if (!shouldReset) { @@ -215,5 +230,5 @@ export const legacyResolveSmartTargetEndpoint = Effect.fnUntraced(function* ( ), ); } - return legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); + return yield* legacyLocalEndpoint(local, yield* LegacyDnsResolverFlag); }); diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts index f1b0087baa..39e3ef6a6e 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.handler.ts @@ -105,11 +105,9 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec if (Option.isSome(flags.linked)) exclusive.push("linked"); if (Option.isSome(flags.local)) exclusive.push("local"); if (exclusive.length > 1) { - return yield* Effect.fail( - new LegacyDeclarativeMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${exclusive.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDeclarativeMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${exclusive.join(" ")}] were all set`, + }); } // Explicit `--linked`: Go re-loads config with the resolved ref (root @@ -148,12 +146,10 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec workdirFromOutput !== ".." && !workdirFromOutput.startsWith(`..${path.sep}`)); if (declarativeDirRel.trim().length === 0 || outputContainsWorkdir) { - return yield* Effect.fail( - new LegacyDeclarativeWriteError({ - message: - "declarative output directory must not be empty, resolve to the project directory, or contain the project directory", - }), - ); + return yield* new LegacyDeclarativeWriteError({ + message: + "declarative output directory must not be empty, resolve to the project directory, or contain the project directory", + }); } yield* legacyWarnFormerDeclarativeDefault(fs, path, cliConfig.workdir, toml.pgDelta); const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); @@ -181,7 +177,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec declarativeDirDisplay: declarativeDirRel, schema: flags.schema, noCache: flags.noCache, - debug: legacyIsPgDeltaDebugEnabled(), + debug: legacyIsPgDeltaDebugEnabled(toml.projectEnv), strictCoverage: flags.strictCoverage, dnsResolver, ...(linkedProjectRef !== undefined ? { linkedProjectRef } : {}), @@ -200,22 +196,20 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec // `ensureLocalDatabaseStarted` (`db_schema_declarative.go:190`), which // short-circuits `if !local { return nil }` (`:127-128`). So `--local=false` // selects the local target but must NOT start a stopped stack. - yield* seam.ensureLocalPostgresImageCurrent(); + yield* seam.ensureLocalPostgresImageCurrent; if (Option.getOrElse(flags.local, () => false)) { - yield* seam.ensureLocalDatabaseStarted(); + yield* seam.ensureLocalDatabaseStarted; } - target = legacyLocalEndpoint(local, dnsResolver); + target = yield* legacyLocalEndpoint(local, dnsResolver); } else { target = yield* legacyResolveRemoteEndpoint(flags); } overwrite = flags.overwrite; } else { if (!tty.stdinIsTty && !yes) { - return yield* Effect.fail( - new LegacyDeclarativeNonInteractiveError({ - message: "in non-interactive mode, specify a target: --local, --linked, or --db-url", - }), - ); + return yield* new LegacyDeclarativeNonInteractiveError({ + message: "in non-interactive mode, specify a target: --local, --linked, or --db-url", + }); } if ((yield* hasDeclarativeFiles(fs, declarativeDir)) && !flags.overwrite) { // Go asks via Console.PromptYesNo (db_schema_declarative.go:268-270, @@ -270,7 +264,7 @@ export const legacyDbSchemaDeclarativeGenerate = Effect.fn("legacy.db.schema.dec path, cliConfig.workdir, linkedRef, - (yield* LegacyDeclarativeSeam).ensureLocalPostgresImageCurrent(), + (yield* LegacyDeclarativeSeam).ensureLocalPostgresImageCurrent, ); overwrite = true; } diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 74c0597144..55a139119c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -1,9 +1,16 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; import { @@ -37,6 +44,8 @@ import { LegacyYesFlag, } from "../../../../../../shared/legacy/global-flags.ts"; import { LegacyGoProxy } from "../../../../../../shared/legacy/go-proxy.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../../../shared/legacy/legacy-viper-env.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../../../../shared/legacy-local-gateway-http-client.ts"; import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.service.ts"; import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; @@ -60,7 +69,23 @@ import { import type { LegacyDbSchemaDeclarativeGenerateFlags } from "./generate.command.ts"; import { legacyDbSchemaDeclarativeGenerate } from "./generate.handler.ts"; -const EXPORT_JSON = JSON.stringify({ +const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const join = (...parts: ReadonlyArray<string>) => pathService.join(...parts); +const dirname = (value: string) => pathService.dirname(value); + +const JsonValueSchema = Schema.fromJsonString(Schema.Unknown); +const encodeJsonValue = Schema.encodeSync(JsonValueSchema); + +const ExportManifestSchema = Schema.Struct({ + formatVersion: Schema.Finite, + profile: Schema.String, + files: Schema.Array(Schema.String), + redactSecrets: Schema.Boolean, + scope: Schema.String, +}); +const decodeExportManifest = Schema.decodeSync(Schema.fromJsonString(ExportManifestSchema)); + +const EXPORT_JSON = encodeJsonValue({ version: 1, mode: "declarative", files: [ @@ -73,8 +98,55 @@ const EXPORT_JSON = JSON.stringify({ ], }); +const withBunServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => + effect.pipe(Effect.provide(BunServices.layer)); +const makeDirectory = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }), + ); +const writeFile = (path: string, contents: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }), + ); +const readFile = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }), + ); +const fileExists = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }), + ); +const removePath = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true, force: true }); + }), + ); +const makeTempDirectory = (prefix: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix }); + }), + ); + interface SetupOpts { experimental?: boolean; + homeDir?: string; + env?: Readonly<Record<string, string>>; args?: ReadonlyArray<string>; yes?: boolean; stdinIsTty?: boolean; @@ -96,6 +168,10 @@ interface SetupOpts { } function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ promptConfirmResponses: opts.promptConfirmResponses, promptSelectResponses: opts.promptSelectResponses, @@ -145,24 +221,22 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? Effect.fail(new LegacyDeclarativeShadowDbError({ message: `export failed for ${mode}` })) : Effect.succeed("supabase/.temp/pgdelta/base.json"); }, - ensureLocalDatabaseStarted: () => - Effect.sync(() => { - ensureStartedCalls += 1; - }), - ensureLocalPostgresImageCurrent: () => - Effect.sync(() => { - localPostgresImageChecks.push(true); - }).pipe( - Effect.flatMap(() => - opts.staleLocalImage === true - ? Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: "local Postgres container image is stale", - }), - ) - : Effect.void, - ), + ensureLocalDatabaseStarted: Effect.sync(() => { + ensureStartedCalls += 1; + }), + ensureLocalPostgresImageCurrent: Effect.sync(() => { + localPostgresImageChecks.push(true); + }).pipe( + Effect.flatMap(() => + opts.staleLocalImage === true + ? Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: "local Postgres container image is stale", + }), + ) + : Effect.void, ), + ), }); const edgeCalls: LegacyEdgeRuntimeRunOpts[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { @@ -197,7 +271,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }); - const runtimeInfo = mockRuntimeInfo({ platform: "linux" }); + const runtimeInfo = mockRuntimeInfo({ platform: "linux", homeDir: opts.homeDir }); const processControl = mockProcessControl(); const experimentalFlag = Layer.succeed(LegacyExperimentalFlag, opts.experimental ?? true); const cliArgs = Layer.succeed(CliArgs, { @@ -225,6 +299,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { dockerRun, BunServices.layer, child.layer, + makeLegacyViperEnvLayer(configProvider), ); const engine = opts.engineImplementation === "next" @@ -246,6 +321,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { : legacyPgDeltaLegacyEngineLayer.pipe(Layer.provide(engineRuntime)); const layer = Layer.mergeAll( out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), telemetry.layer, cache.layer, seam, @@ -278,10 +355,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { runtimeInfo, processControl.layer, alwaysReadyHttpClientLayer, + legacyLocalGatewayHttpClientTestLayer(alwaysReadyHttpClientLayer), dockerRun, ); return { layer, + configProvider, out, cache, telemetry, @@ -378,18 +457,16 @@ describe("legacy db schema declarative generate integration", () => { // so an env-only experimental session still opens the gate and lets the mutex // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is // what makes the TS gate honor the env var the same way. - const { layer } = setup(tmp.current, { experimental: false }); - const ENV = "SUPABASE_EXPERIMENTAL"; + const { layer } = setup(tmp.current, { + experimental: false, + env: { SUPABASE_EXPERIMENTAL: "1" }, + }); return Effect.gen(function* () { - const saved = process.env[ENV]; - process.env[ENV] = "1"; const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), linked: Option.some(true) }), ), ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", @@ -410,16 +487,12 @@ describe("legacy db schema declarative generate integration", () => { const { layer } = setup(tmp.current, { experimental: false, args: ["db", "schema", "declarative", "generate", "--experimental=false"], + env: { SUPABASE_EXPERIMENTAL: "1" }, }); - const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { - const saved = process.env[ENV]; - process.env[ENV] = "1"; const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); }).pipe(Effect.provide(layer)); @@ -435,12 +508,10 @@ describe("legacy db schema declarative generate integration", () => { // present at commit 7b469f5b3; pkg/config/config.go:789), so a // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex // check fire, same as the shell-env case above. - const saved = process.env["SUPABASE_EXPERIMENTAL"]; - delete process.env["SUPABASE_EXPERIMENTAL"]; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), linked: Option.some(true) }), @@ -452,15 +523,7 @@ describe("legacy db schema declarative generate integration", () => { message: "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", }); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = saved; - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); @@ -468,10 +531,13 @@ describe("legacy db schema declarative generate integration", () => { // Upgrade path: the implicit default moved from supabase/database to // supabase/schemas; a project relying on the old default must be told before // a fresh tree is generated somewhere its existing files are not. - mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "database", "public.sql"), "create table a();"); const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "database")); + yield* writeFile( + join(tmp.current, "supabase", "database", "public.sql"), + "create table a();", + ); yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); expect(stripAnsi(s.out.stderrText)).toContain( "WARNING: found declarative schema files in supabase/database, but the default declarative directory is now supabase/schemas.", @@ -493,11 +559,8 @@ describe("legacy db schema declarative generate integration", () => { expect(s.edgeCalls[0]!.env["TARGET"]).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); - const written = yield* Effect.promise(async () => - (await import("node:fs")).readFileSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), - "utf8", - ), + const written = yield* readFile( + join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), ); expect(written).toBe("create table players ();"); // Go prints the relative `utils.GetDeclarativeDir()` verbatim @@ -517,8 +580,6 @@ describe("legacy db schema declarative generate integration", () => { it.effect( "--output-dir writes a complete next export relative to the project without activating it", () => { - mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "select 1;"); const configPath = join(tmp.current, "supabase", "config.toml"); const config = [ "[experimental.pgdelta]", @@ -526,28 +587,32 @@ describe("legacy db schema declarative generate integration", () => { 'declarative_schema_path = "supabase/database"', "", ].join("\n"); - writeFileSync(configPath, config); const destination = join("supabase", "database-next"); const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "database")); + yield* writeFile(join(tmp.current, "supabase", "database", "configured.sql"), "select 1;"); + yield* writeFile(configPath, config); yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some(destination) }), ); expect( - readFileSync(join(tmp.current, destination, "public", "tables", "players.sql"), "utf8"), + yield* readFile(join(tmp.current, destination, "public", "tables", "players.sql")), ).toBe("create table players ();"); expect( - JSON.parse(readFileSync(join(tmp.current, destination, ".pgdelta-export.json"), "utf8")), + decodeExportManifest( + yield* readFile(join(tmp.current, destination, ".pgdelta-export.json")), + ), ).toMatchObject({ formatVersion: 1, profile: "supabase", files: ["public/tables/players.sql"], }); - expect( - readFileSync(join(tmp.current, "supabase", "database", "configured.sql"), "utf8"), - ).toBe("select 1;"); - expect(readFileSync(configPath, "utf8")).toBe(config); + expect(yield* readFile(join(tmp.current, "supabase", "database", "configured.sql"))).toBe( + "select 1;", + ); + expect(yield* readFile(configPath)).toBe(config); expect( s.out.rawChunks.map((chunk) => ({ text: stripAnsi(chunk.text), stream: chunk.stream })), ).toContainEqual({ @@ -560,30 +625,30 @@ describe("legacy db schema declarative generate integration", () => { it.effect("--output-dir protects a non-empty destination without --overwrite", () => { const destination = join(tmp.current, "staged-schema"); - mkdirSync(destination, { recursive: true }); - writeFileSync(join(destination, "keep.sql"), "select 'keep';"); const s = setup(tmp.current, { experimental: true, engineImplementation: "next", promptConfirmResponses: [false], }); return Effect.gen(function* () { + yield* makeDirectory(destination); + yield* writeFile(join(destination, "keep.sql"), "select 'keep';"); yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some(destination) }), ); - expect(readFileSync(join(destination, "keep.sql"), "utf8")).toBe("select 'keep';"); - expect(existsSync(join(destination, ".pgdelta-export.json"))).toBe(false); + expect(yield* readFile(join(destination, "keep.sql"))).toBe("select 'keep';"); + expect(yield* fileExists(join(destination, ".pgdelta-export.json"))).toBe(false); expect(s.out.rawChunks.some((chunk) => chunk.text.includes("Skipped writing"))).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect("rejects output paths that could overwrite the project or an ancestor", () => { const projectDir = join(tmp.current, "project"); - mkdirSync(projectDir, { recursive: true }); const sentinel = join(projectDir, "project-sentinel.txt"); - writeFileSync(sentinel, "keep"); const s = setup(projectDir, { experimental: true, engineImplementation: "next" }); return Effect.gen(function* () { + yield* makeDirectory(projectDir); + yield* writeFile(sentinel, "keep"); for (const output of ["", ".", "..", dirname(projectDir)]) { const exit = yield* legacyDbSchemaDeclarativeGenerate( flags({ local: Option.some(true), outputDir: Option.some(output), overwrite: true }), @@ -594,7 +659,7 @@ describe("legacy db schema declarative generate integration", () => { message: "declarative output directory must not be empty, resolve to the project directory, or contain the project directory", }); - expect(readFileSync(sentinel, "utf8")).toBe("keep"); + expect(yield* readFile(sentinel)).toBe("keep"); } expect(s.localPostgresImageChecks).toEqual([]); }).pipe(Effect.provide(s.layer)); @@ -608,11 +673,11 @@ describe("legacy db schema declarative generate integration", () => { ); expect(s.seamCalls).toEqual([]); expect( - existsSync( + yield* fileExists( join(tmp.current, "staged-schema", "schemas", "public", "tables", "players.sql"), ), ).toBe(true); - expect(existsSync(join(tmp.current, "supabase", "schemas"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "schemas"))).toBe(false); }).pipe(Effect.provide(s.layer)); }); @@ -638,16 +703,16 @@ describe("legacy db schema declarative generate integration", () => { // Go's confirmOverwrite returns true immediately (Console.PromptYesNo); the // handler must skip the prompt and overwrite. No promptConfirmResponses are // queued, so reaching the prompt would error — success proves --yes bypassed it. - mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "schemas", "existing.sql"), "create table x ();"); const s = setup(tmp.current, { experimental: true, yes: true }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "schemas")); + yield* writeFile( + join(tmp.current, "supabase", "schemas", "existing.sql"), + "create table x ();", + ); yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - const written = yield* Effect.promise(async () => - (await import("node:fs")).readFileSync( - join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), - "utf8", - ), + const written = yield* readFile( + join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), ); expect(written).toBe("create table players ();"); }).pipe(Effect.provide(s.layer)); @@ -659,19 +724,17 @@ describe("legacy db schema declarative generate integration", () => { // dir as empty and letting WriteDeclarativeSchemas wipe/recreate the path. // Seeding supabase/schemas as a FILE makes readDirectory fail with ENOTDIR (a // non-NotFound PlatformError), so the command must fail without writing. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "schemas"), "not a directory"); const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile(join(tmp.current, "supabase", "schemas"), "not a directory"); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), ); expect(Exit.isFailure(exit)).toBe(true); // The declarative path is untouched — still our seeded file, never wiped and // rewritten as a directory of schema files. - expect(readFileSync(join(tmp.current, "supabase", "schemas"), "utf8")).toBe( - "not a directory", - ); + expect(yield* readFile(join(tmp.current, "supabase", "schemas"))).toBe("not a directory"); expect(s.out.rawChunks.some((c) => c.text.includes("Declarative schema written to"))).toBe( false, ); @@ -694,25 +757,27 @@ describe("legacy db schema declarative generate integration", () => { it.effect("writes to an absolute declarative_schema_path as-is (no workdir prefix)", () => { // Go's config resolver leaves an absolute declarative_schema_path unchanged; path.join // would mangle /repo + /abs into /repo/abs. - const absSchema = mkdtempSync(join(tmpdir(), "legacy-decl-abs-")); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[experimental.pgdelta]", - "enabled = true", - `declarative_schema_path = "${absSchema}"`, - "", - ].join("\n"), - ); const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { + const absSchema = yield* makeTempDirectory("legacy-decl-abs-"); + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = true", + `declarative_schema_path = "${absSchema}"`, + "", + ].join("\n"), + ); yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); // File lands under the absolute path, NOT tmp.current/<absSchema>. - expect(existsSync(join(absSchema, "schemas", "public", "tables", "players.sql"))).toBe(true); - expect( - readFileSync(join(absSchema, "schemas", "public", "tables", "players.sql"), "utf8"), - ).toBe("create table players ();"); + expect(yield* fileExists(join(absSchema, "schemas", "public", "tables", "players.sql"))).toBe( + true, + ); + expect(yield* readFile(join(absSchema, "schemas", "public", "tables", "players.sql"))).toBe( + "create table players ();", + ); // Go prints the configured value verbatim — absolute here, never workdir-prefixed. expect( s.out.rawChunks.map((c) => ({ text: stripAnsi(c.text), stream: c.stream })), @@ -720,7 +785,7 @@ describe("legacy db schema declarative generate integration", () => { text: `Declarative schema written to ${absSchema}\n`, stream: "stderr", }); - rmSync(absSchema, { recursive: true, force: true }); + yield* removePath(absSchema); }).pipe(Effect.provide(s.layer)); }); @@ -729,35 +794,32 @@ describe("legacy db schema declarative generate integration", () => { // [remotes.<ref>] block overrides experimental.pgdelta.declarative_schema_path — // the declarative files must land under the remote-overridden path. const ref = "abcdefghijklmnopqrst"; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - 'project_id = "base"', - "[experimental.pgdelta]", - "enabled = true", - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental.pgdelta]", - 'declarative_schema_path = "remote_schema"', - "", - ].join("\n"), - ); const s = setup(tmp.current, { experimental: true, projectId: Option.some(ref) }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile( + join(tmp.current, "supabase", "config.toml"), + [ + 'project_id = "base"', + "[experimental.pgdelta]", + "enabled = true", + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental.pgdelta]", + 'declarative_schema_path = "remote_schema"', + "", + ].join("\n"), + ); yield* legacyDbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })); - const written = yield* Effect.promise(async () => - (await import("node:fs")).readFileSync( - join( - tmp.current, - "supabase", - "remote_schema", - "schemas", - "public", - "tables", - "players.sql", - ), - "utf8", + const written = yield* readFile( + join( + tmp.current, + "supabase", + "remote_schema", + "schemas", + "public", + "tables", + "players.sql", ), ); expect(written).toBe("create table players ();"); @@ -815,20 +877,20 @@ describe("legacy db schema declarative generate integration", () => { // root ParseDatabaseConfig reloads the remote block, so a remote enabled=true must NOT // enable a base-disabled command without --experimental. const ref = "abcdefghijklmnopqrst"; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - 'project_id = "base"', - "[remotes.prod]", - `project_id = "${ref}"`, - "[remotes.prod.experimental.pgdelta]", - "enabled = true", - "", - ].join("\n"), - ); const s = setup(tmp.current, { experimental: false, projectId: Option.some(ref) }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile( + join(tmp.current, "supabase", "config.toml"), + [ + 'project_id = "base"', + "[remotes.prod]", + `project_id = "${ref}"`, + "[remotes.prod.experimental.pgdelta]", + "enabled = true", + "", + ].join("\n"), + ); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeGenerate(flags({ linked: Option.some(true) })), ); @@ -851,14 +913,14 @@ describe("legacy db schema declarative generate integration", () => { it.effect("smart mode: existing files + decline regenerate → skips", () => { const declDir = join(tmp.current, "supabase", "schemas"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "existing.sql"), "-- existing"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, promptConfirmResponses: [false], }); return Effect.gen(function* () { + yield* makeDirectory(declDir); + yield* writeFile(join(declDir, "existing.sql"), "-- existing"); yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.seamCalls).toEqual([]); expect( @@ -873,10 +935,10 @@ describe("legacy db schema declarative generate integration", () => { // no prompt is shown. No migrations → the smart target resolves to local without // a further prompt. No promptConfirmResponses are queued, so a prompt would throw. const declDir = join(tmp.current, "supabase", "schemas"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "existing.sql"), "-- existing"); const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true }); return Effect.gen(function* () { + yield* makeDirectory(declDir); + yield* writeFile(join(declDir, "existing.sql"), "-- existing"); yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.seamCalls).toEqual(["declarative"]); // Go's PromptYesNo echoes the auto-accepted question to stderr under the @@ -895,26 +957,21 @@ describe("legacy db schema declarative generate integration", () => { // Go reads `viper.GetBool("YES")`, which `AutomaticEnv` also binds to the // SUPABASE_YES env var — the flag alone is not the whole surface (CLI-1974). const declDir = join(tmp.current, "supabase", "schemas"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "existing.sql"), "-- existing"); - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: false }); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: false, + yes: false, + env: { SUPABASE_YES: "1" }, + }); return Effect.gen(function* () { + yield* makeDirectory(declDir); + yield* writeFile(join(declDir, "existing.sql"), "-- existing"); yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.seamCalls).toEqual(["declarative"]); expect(stripAnsi(s.out.stderrText)).toContain( `Declarative schema already exists at ${join("supabase", "schemas")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, ); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }); it.effect("warms the declarative catalog cache after writing (skipped with --no-cache)", () => { @@ -945,8 +1002,6 @@ describe("legacy db schema declarative generate integration", () => { // Go runs reset in-process and returns the error; `legacyResetLocalDatabase` now // runs the same way (CLI-2062), so its real failure must fail the effect (so // telemetry flush / error handling run) rather than process.exit via LegacyGoProxy. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -954,6 +1009,8 @@ describe("legacy db schema declarative generate integration", () => { resetShouldFail: true, }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags({ reset: true }))); expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ @@ -968,8 +1025,6 @@ describe("legacy db schema declarative generate integration", () => { // Go's runDeclarativeGenerate adds a "Linked project" choice when LoadProjectRef // succeeds; selecting it builds the URL via NewDbConfigWithPassword (the --linked // path). Use a valid 20-char ref so the choice is shown. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -977,6 +1032,8 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["linked"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* legacyDbSchemaDeclarativeGenerate(flags()); // The prompt offered the linked choice, and selecting it routed through the // resolver's --linked branch. @@ -987,8 +1044,6 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect("smart mode: local target checks the local Postgres image before generating", () => { - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -996,6 +1051,8 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags())); expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ @@ -1015,8 +1072,6 @@ describe("legacy db schema declarative generate integration", () => { // ensureProjectGroupsCached then writes the linked-project cache regardless of // which target the user picks (cmd/root.go:176,214-218). So a linked workdir + // smart mode + "Local database" choice must still cache. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1025,6 +1080,8 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* legacyDbSchemaDeclarativeGenerate(flags()); expect(s.cache.cached).toBe(true); // This scenario also runs a real in-process local reset @@ -1057,8 +1114,6 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect("smart mode: hides the linked choice when the workdir is not linked", () => { - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1066,6 +1121,8 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* legacyDbSchemaDeclarativeGenerate(flags()); const options = s.out.promptSelectCalls[0]?.options ?? []; expect(options.map((o) => o.value)).toEqual(["local", "custom"]); @@ -1077,10 +1134,10 @@ describe("legacy db schema declarative generate integration", () => { // (db_schema_declarative.go:164-169), flowing into the no-migrations local generate. // Seeding supabase/migrations as a FILE makes the list fail with ENOTDIR — the smart // probe must swallow it and proceed, not abort. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const s = setup(tmp.current, { experimental: true, yes: true }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile(join(tmp.current, "supabase", "migrations"), "not a directory"); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags())); expect(Exit.isSuccess(exit)).toBe(true); // No migrations → local generate path started the stack (not aborted on the read). @@ -1093,9 +1150,6 @@ describe("legacy db schema declarative generate integration", () => { // (db_schema_declarative.go:222-224): a broken .temp/project-ref omits the linked // choice and local/custom generation proceeds. Seeding project-ref as a DIRECTORY // makes the read fail; the smart read must swallow it, not abort. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); - mkdirSync(join(tmp.current, "supabase", ".temp", "project-ref"), { recursive: true }); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1104,6 +1158,9 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + yield* makeDirectory(join(tmp.current, "supabase", ".temp", "project-ref")); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags())); expect(Exit.isSuccess(exit)).toBe(true); // Linked choice omitted (ref unreadable), and nothing cached as linked. @@ -1119,13 +1176,10 @@ describe("legacy db schema declarative generate integration", () => { // Go's Console.PromptYesNo auto-returns true under the global --yes flag, so the // "Reset local database to match migrations first?" prompt must be skipped and the // reset must run. No promptConfirmResponses are supplied, so a prompt would throw. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); // `legacyResetLocalDatabase`'s container-recreate resolves its own project id from // `@supabase/config` (config.toml / real env), independently of the mocked // `LegacyCliConfig.projectId` — pin it to "test" so the recreated container name // matches the spawner route's assumption. - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1133,6 +1187,10 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); yield* legacyDbSchemaDeclarativeGenerate(flags()); // The reset actually ran — recreated the local `db` container in-process // (CLI-2062: no `supabase-go` child) — proving it's a real effect. @@ -1146,9 +1204,6 @@ describe("legacy db schema declarative generate integration", () => { // `legacyResetLocalDatabase` resolves `LegacyNetworkIdFlag` itself from the // shared context (CLI-2062) — no argv-forwarding needed — so the recreated // container must land on the custom network directly. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1157,6 +1212,10 @@ describe("legacy db schema declarative generate integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeFile(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); yield* legacyDbSchemaDeclarativeGenerate(flags()); const createArgs = legacyLocalResetCreateArgs(s.child.spawned); const networkIndex = createArgs?.indexOf("--network") ?? -1; @@ -1168,8 +1227,6 @@ describe("legacy db schema declarative generate integration", () => { it.effect("smart mode: rejects a malformed custom database URL", () => { // Go parses the custom URL with pgconn.ParseConfig and fails with // "failed to parse connection string: ..." rather than passing it to pg-delta. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1177,6 +1234,8 @@ describe("legacy db schema declarative generate integration", () => { promptTextResponses: ["not a url"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeGenerate(flags())); expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ @@ -1187,8 +1246,6 @@ describe("legacy db schema declarative generate integration", () => { }); it.effect("smart mode: normalizes a valid custom database URL before pg-delta", () => { - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1196,18 +1253,78 @@ describe("legacy db schema declarative generate integration", () => { promptTextResponses: ["postgres://user:secret@db.example.com:5432/app"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* legacyDbSchemaDeclarativeGenerate(flags()); // Normalized via ToPostgresURL → connect_timeout appended, like Go. expect(s.edgeCalls[0]!.env["TARGET"]).toContain("@db.example.com:5432/app?connect_timeout="); }).pipe(Effect.provide(s.layer)); }); + it.effect("smart mode: custom target honors libpq service and pgpass defaults", () => { + const homeDir = join(tmp.current, "home"); + const s = setup(tmp.current, { + experimental: true, + homeDir, + env: { PGSERVICE: "analytics" }, + stdinIsTty: true, + promptSelectResponses: ["custom"], + promptTextResponses: ["postgresql:///"], + }); + return Effect.gen(function* () { + yield* makeDirectory(homeDir); + yield* writeFile( + join(homeDir, ".pg_service.conf"), + "[analytics]\nhost=service.example.com\nport=6543\nuser=service_user\ndbname=service_db\n", + ); + yield* writeFile( + join(homeDir, ".pgpass"), + "service.example.com:6543:service_db:service_user:file-password\n", + ); + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + yield* legacyDbSchemaDeclarativeGenerate(flags()); + const target = s.edgeCalls[0]!.env["TARGET"]; + expect(target).toContain("service.example.com:6543/service_db"); + expect(target).toContain("file-password"); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect("smart mode: custom target honors explicit servicefile and passfile paths", () => { + const serviceFile = join(tmp.current, "explicit-service.conf"); + const passFile = join(tmp.current, "explicit.pgpass"); + const s = setup(tmp.current, { + experimental: true, + stdinIsTty: true, + promptSelectResponses: ["custom"], + promptTextResponses: [ + `postgresql:///?service=analytics&servicefile=${serviceFile}&passfile=${passFile}`, + ], + }); + return Effect.gen(function* () { + yield* writeFile( + serviceFile, + "[analytics]\nhost=explicit.example.com\nport=6544\nuser=explicit_user\ndbname=explicit_db\n", + ); + yield* writeFile( + passFile, + "explicit.example.com:6544:explicit_db:explicit_user:explicit-password\n", + ); + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeFile(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + yield* legacyDbSchemaDeclarativeGenerate(flags()); + const target = s.edgeCalls[0]!.env["TARGET"]; + expect(target).toContain("explicit.example.com:6544/explicit_db"); + expect(target).toContain("explicit-password"); + }).pipe(Effect.provide(s.layer)); + }); + it.effect("next engine writes its manifest and skips legacy catalog warming", () => { const s = setup(tmp.current, { experimental: true, engineImplementation: "next" }); return Effect.gen(function* () { yield* legacyDbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - const manifest = JSON.parse( - readFileSync(join(tmp.current, "supabase", "schemas", ".pgdelta-export.json"), "utf8"), + const manifest = decodeExportManifest( + yield* readFile(join(tmp.current, "supabase", "schemas", ".pgdelta-export.json")), ); expect(manifest).toMatchObject({ formatVersion: 1, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts index f39d724f50..89c078dd37 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this e2e test drives the real CLI and inspects host files. import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { afterAll, beforeAll, expect, test } from "vitest"; diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 6147f565bc..860c53eb64 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -1,4 +1,4 @@ -import { Cause, Clock, Effect, Exit, FileSystem, Option, Path, Result } from "effect"; +import { Cause, Clock, DateTime, Effect, Exit, FileSystem, Option, Path, Result } from "effect"; import { LegacyDnsResolverFlag, @@ -85,7 +85,7 @@ const DEFAULT_SYNC_NAME = "declarative_sync"; /** Go's `GetCurrentTimestamp`: UTC `YYYYMMDDHHmmss`. */ const formatTimestamp = (millis: number): string => - new Date(millis).toISOString().replace(/\D/g, "").slice(0, 14); + DateTime.formatIso(DateTime.makeUnsafe(millis)).replace(/\D/gu, "").slice(0, 14); // Go's debug-bundle id layout `20060102-150405` (UTC) — hoisted to // `legacy-debug-bundle.ts` and reused by the `db pull` empty-diff bundle. @@ -146,11 +146,9 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (Option.isSome(flags.apply)) exclusive.push("apply"); if (Option.isSome(flags.noApply)) exclusive.push("no-apply"); if (exclusive.length > 1) { - return yield* Effect.fail( - new LegacyDeclarativeMutuallyExclusiveFlagsError({ - message: `if any flags in the group [apply no-apply] are set none of the others can be; [${exclusive.join(" ")}] were all set`, - }), - ); + return yield* new LegacyDeclarativeMutuallyExclusiveFlagsError({ + message: `if any flags in the group [apply no-apply] are set none of the others can be; [${exclusive.join(" ")}] were all set`, + }); } // Go's `utils.GetDeclarativeDir()` — the config value verbatim (already @@ -186,11 +184,11 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara declarativeDirDisplay: declarativeDirRel, schema: flags.schema, noCache: flags.noCache, - debug: legacyIsPgDeltaDebugEnabled(), + debug: legacyIsPgDeltaDebugEnabled(toml.projectEnv), strictCoverage: flags.strictCoverage, dnsResolver, }; - const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent(); + const ensureLocalPostgresImageCurrent = seam.ensureLocalPostgresImageCurrent; yield* legacyWarnFormerDeclarativeDefault(fs, path, cliConfig.workdir, toml.pgDelta); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); @@ -215,7 +213,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const noFiles = new LegacyDeclarativeNonInteractiveError({ message: "no declarative schema found. Run supabase db schema declarative generate first", }); - if (!tty.stdinIsTty && !yes) return yield* Effect.fail(noFiles); + if (!tty.stdinIsTty && !yes) return yield* noFiles; // Go asks via Console.PromptYesNo (db_schema_declarative.go:381, default // true): --yes/SUPABASE_YES auto-confirms WITH the `<label> [Y/n] y` // stderr echo (console.go:70-72) — routed through `legacyPromptYesNo` @@ -226,7 +224,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara "No declarative schema found. Generate a new one ?", true, ); - if (!ok) return yield* Effect.fail(noFiles); + if (!ok) return yield* noFiles; // Go delegates to the full smart-generate flow (`runDeclarativeGenerate`, // db_schema_declarative.go:321): with migrations present it offers the // local / linked / custom target choice + local-reset prompt, so a linked @@ -280,11 +278,9 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara // files go straight into the plan below — warn before diffing against them. yield* legacyWarnPreservedUnmanagedDeclarativeFiles(declarativeDirRel, written); if (!(yield* declarativeDirHasFiles(fs, declarativeDir))) { - return yield* Effect.fail( - new LegacyDeclarativeNoFilesGeneratedError({ - message: "declarative schema generation did not produce any files", - }), - ); + return yield* new LegacyDeclarativeNoFilesGeneratedError({ + message: "declarative schema generation did not produce any files", + }); } // Go's bootstrap delegates to the full `declarative.Generate`, which warms the // declarative catalog cache when --no-cache is unset (`declarative.go:133-157`, @@ -328,11 +324,9 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara stagedRelative === "" || (!stagedRelative.startsWith("..") && !path.isAbsolute(stagedRelative)) ) { - return yield* Effect.fail( - new LegacyDeclarativeCompatibilityError({ - message: `${stagedDirRel} is inside the active declarative schema directory; choose a different staging directory.`, - }), - ); + return yield* new LegacyDeclarativeCompatibilityError({ + message: `${stagedDirRel} is inside the active declarative schema directory; choose a different staging directory.`, + }); } const stagedExists = yield* fs.exists(stagedDir).pipe(Effect.orElseSucceed(() => false)); if (stagedExists) { @@ -341,15 +335,13 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara fs.exists(path.join(stagedDir, ".pgdelta-export.json")), ]); if (entries.length > 0 && !hasManifest) { - return yield* Effect.fail( - new LegacyDeclarativeCompatibilityError({ - message: `${stagedDirRel} already contains files without a pg-delta export manifest. Move or remove that directory, then run sync again so the staged export cannot preserve unrelated SQL.`, - }), - ); + return yield* new LegacyDeclarativeCompatibilityError({ + message: `${stagedDirRel} already contains files without a pg-delta export manifest. Move or remove that directory, then run sync again so the staged export cannot preserve unrelated SQL.`, + }); } } yield* ensureLocalPostgresImageCurrent; - yield* seam.ensureLocalDatabaseStarted(); + yield* seam.ensureLocalDatabaseStarted; // The staged export snapshots the RUNNING local database verbatim — not a // shadow built from migrations, which is what the failed plan compared. Say // so, and offer the same reset the smart-target local path offers, so stale @@ -380,7 +372,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara const generated = yield* legacyGenerateDeclarativeOutput( { ...run, declarativeDir: stagedDir }, toml, - legacyLocalEndpoint({ port: toml.port, password: toml.password }, dnsResolver), + yield* legacyLocalEndpoint({ port: toml.port, password: toml.password }, dnsResolver), ); const written = yield* legacyWriteDeclarativeSchemas(fs, path, stagedDir, generated); yield* legacyWarnPreservedUnmanagedDeclarativeFiles(stagedDirRel, written); @@ -443,24 +435,22 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara !(error instanceof LegacyDeclarativeCompatibilityError) || error.loadFindings === undefined ) { - return yield* Effect.fail(error); + return yield* error; } const missingExtensions = [ ...new Set(error.loadFindings.map((finding) => finding.extension)), ].sort(); if (missingExtensions.includes("pg_net") && !toml.webhooksEnabled) { - return yield* Effect.fail( - new LegacyDeclarativeCompatibilityError({ - message: [ - "The declarative schema uses pg_net, but Database Webhooks are not enabled in the local project config.", - "", - LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION, - ].join("\n"), - }), - ); + return yield* new LegacyDeclarativeCompatibilityError({ + message: [ + "The declarative schema uses pg_net, but Database Webhooks are not enabled in the local project config.", + "", + LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION, + ].join("\n"), + }); } - if (!tty.stdinIsTty || yes) return yield* Effect.fail(error); + if (!tty.stdinIsTty || yes) return yield* error; yield* output.raw(`${legacyYellow(error.message)}\n`, "stderr"); const choice = yield* output.promptSelect("How would you like to continue?", [ @@ -506,15 +496,13 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara !toml.webhooksEnabled && result.removals.extensions.includes("pg_net") ) { - return yield* Effect.fail( - new LegacyDeclarativeCompatibilityError({ - message: [ - "The migrations state includes pg_net, but Database Webhooks are not enabled in the local project config.", - "", - LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION, - ].join("\n"), - }), - ); + return yield* new LegacyDeclarativeCompatibilityError({ + message: [ + "The migrations state includes pg_net, but Database Webhooks are not enabled in the local project config.", + "", + LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION, + ].join("\n"), + }); } const compatibility = legacyClassifyDeclarativeCompatibilityGap({ implementation: engine.implementation, @@ -537,12 +525,10 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara }, }); if (!tty.stdinIsTty || yes) { - return yield* Effect.fail( - new LegacyDeclarativeCompatibilityError({ - message: gate.message, - suggestion: gate.suggestion, - }), - ); + return yield* new LegacyDeclarativeCompatibilityError({ + message: gate.message, + suggestion: gate.suggestion, + }); } yield* output.raw(`${legacyYellow(gate.message)}\n`, "stderr"); @@ -758,7 +744,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara ); } yield* output.raw(legacyDebugBundleMessage(""), "stderr"); - return yield* Effect.fail(resetError); + return yield* resetError; } yield* output.raw("Database reset and all migrations applied successfully.\n", "stderr"); return; @@ -769,7 +755,7 @@ export const legacyDbSchemaDeclarativeSync = Effect.fn("legacy.db.schema.declara if (debugDir.length > 0) { yield* output.raw(legacyDebugBundleMessage(debugDir), "stderr"); } - return yield* Effect.fail(applyError); + return yield* applyError; }).pipe( // Mirror Go's `ensureProjectGroupsCached` PersistentPostRun (`cmd/root.go:176, // 214-218`): when the bootstrap path resolved a linked ref, write the @@ -813,7 +799,7 @@ const applyMigrationToLocal = ( // (`apps/cli-go/cmd/db_schema_declarative.go:463`, deleted in // CLI-1970; last present at commit 7b469f5b3), honoring // SUPABASE_SERVICES_HOSTNAME / tcp DOCKER_HOST — not a hardcoded loopback. - host: legacyGetHostname(), + host: yield* legacyGetHostname, port: local.port, user: "postgres", password: local.password, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts index 26d19fa12f..83273d8e73 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -1,8 +1,17 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Path, + Schema, +} from "effect"; import { stripAnsi } from "../../../../../../../tests/helpers/ansi.ts"; import { @@ -48,10 +57,13 @@ import { LegacyEdgeRuntimeScript, } from "../../../../../shared/legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../../../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../../../shared/legacy/legacy-viper-env.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../../../../shared/legacy-local-gateway-http-client.ts"; import { legacyPgDeltaLegacyEngineLayer } from "../../../shared/legacy-pgdelta-engine.legacy.layer.ts"; import { LegacyPgDeltaEngine, LegacyPgDeltaEngineError, + type LegacyPgDeltaDeclarativeExportResult, type LegacyPgDeltaRemovalSummary, type LegacyPgDeltaRenderedFile, } from "../../../shared/legacy-pgdelta-engine.service.ts"; @@ -60,7 +72,60 @@ import { LegacyDeclarativeSeam } from "../../../shared/legacy-pgdelta.seam.servi import type { LegacyDbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; import { legacyDbSchemaDeclarativeSync } from "./sync.handler.ts"; -const EXPORT_JSON = JSON.stringify({ +const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const join = (...parts: ReadonlyArray<string>) => pathService.join(...parts); +const withBunServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => + effect.pipe(Effect.provide(BunServices.layer)); +const makeDirectory = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }), + ); +const writeText = (path: string, contents: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }), + ); +const readText = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }), + ); +const readDirectory = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(path); + }), + ); +const fileExists = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }), + ); + +const ExportSchema = Schema.Struct({ + version: Schema.Finite, + mode: Schema.String, + files: Schema.Array( + Schema.Struct({ + path: Schema.String, + order: Schema.Finite, + statements: Schema.Finite, + sql: Schema.String, + }), + ), +}); +const encodeExportJson = Schema.encodeSync(Schema.fromJsonString(ExportSchema)); +const EXPORT_JSON = encodeExportJson({ version: 1, mode: "declarative", files: [ @@ -72,6 +137,30 @@ const EXPORT_JSON = JSON.stringify({ }, ], }); +const encodePlanJson = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + version: Schema.Finite, + files: Schema.Array( + Schema.Struct({ + order: Schema.Finite, + name: Schema.String, + transactionMode: Schema.String, + sql: Schema.String, + }), + ), + }), + ), +); +const encodeManifestJson = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + formatVersion: Schema.Finite, + redactSecrets: Schema.Boolean, + scope: Schema.String, + }), + ), +); interface SetupOpts { experimental?: boolean; @@ -98,9 +187,14 @@ interface SetupOpts { renderedFiles?: ReadonlyArray<LegacyPgDeltaRenderedFile>; removals?: LegacyPgDeltaRemovalSummary; planErrors?: ReadonlyArray<LegacyPgDeltaEngineError>; + env?: Readonly<Record<string, string>>; } function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ promptConfirmResponses: opts.promptConfirmResponses, promptSelectResponses: opts.promptSelectResponses, @@ -135,21 +229,20 @@ function setup(workdir: string, opts: SetupOpts = {}) { exportCatalogCalls.push({ mode, rawChunksAt: out.rawChunks.length }); return `supabase/.temp/pgdelta/${mode}.json`; }), - ensureLocalDatabaseStarted: () => Effect.void, - ensureLocalPostgresImageCurrent: () => - Effect.sync(() => { - localPostgresImageChecks.push(true); - }).pipe( - Effect.flatMap(() => - opts.staleLocalImage === true - ? Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: "local Postgres container image is stale", - }), - ) - : Effect.void, - ), + ensureLocalDatabaseStarted: Effect.void, + ensureLocalPostgresImageCurrent: Effect.sync(() => { + localPostgresImageChecks.push(true); + }).pipe( + Effect.flatMap(() => + opts.staleLocalImage === true + ? Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: "local Postgres container image is stale", + }), + ) + : Effect.void, ), + ), }); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { @@ -171,7 +264,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // single-unit envelope so `legacyDiffPgDelta` parses it. const stdout = runOpts.script.includes("renderPlanFiles") && diffSql.length > 0 - ? JSON.stringify({ + ? encodePlanJson({ version: 1, files: [ { @@ -286,6 +379,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { dockerRun, BunServices.layer, child.layer, + makeLegacyViperEnvLayer(configProvider), ); const nextFiles = opts.renderedFiles ?? []; const planErrors = [...(opts.planErrors ?? [])]; @@ -300,46 +394,51 @@ function setup(workdir: string, opts: SetupOpts = {}) { diffExplicit: () => Effect.die("diffExplicit not used in sync tests"), diffDatabase: () => Effect.die("diffDatabase not used in sync tests"), exportDeclarativeSchema: (input) => - Effect.sync(() => { + Effect.sync<LegacyPgDeltaDeclarativeExportResult>(() => { declarativeExportCalls.push(input.schema); return { files: [{ name: "public/tables/players.sql", sql: "create table players ();" }], manifest: { redactSecrets: true, scope: "database", profile: "supabase" }, }; }), - planDeclarativeSchema: () => { - planCalls += 1; - const planError = planErrors.shift(); - if (planError !== undefined) return Effect.fail(planError); - const extensionPath = join(workdir, "supabase", "schemas", "extension.sql"); - const extensionSql = existsSync(extensionPath) - ? readFileSync(extensionPath, "utf8") - : ""; - const remainingExtensions = (opts.removals?.extensions ?? []).filter( - (extension) => !extensionSql.includes(`"${extension}"`), - ); - const extensionsRepaired = - remainingExtensions.length < (opts.removals?.extensions.length ?? 0); - return Effect.succeed({ - changes: nextFiles.length > 0, - sql: - extensionsRepaired && opts.replannedDiffSql !== undefined - ? opts.replannedDiffSql - : (opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n")), - files: nextFiles, - sourceRef: "migrations", - targetRef: "declarative", - removals: - opts.removals === undefined - ? undefined - : { ...opts.removals, extensions: remainingExtensions }, - }); - }, + planDeclarativeSchema: () => + Effect.gen(function* () { + planCalls += 1; + const planError = planErrors.shift(); + if (planError !== undefined) return yield* planError; + const extensionPath = join(workdir, "supabase", "schemas", "extension.sql"); + const extensionSql = (yield* fileExists(extensionPath).pipe( + Effect.mapError(mapPlanFsError), + )) + ? yield* readText(extensionPath).pipe(Effect.mapError(mapPlanFsError)) + : ""; + const remainingExtensions = (opts.removals?.extensions ?? []).filter( + (extension) => !extensionSql.includes(`"${extension}"`), + ); + const extensionsRepaired = + remainingExtensions.length < (opts.removals?.extensions.length ?? 0); + return { + changes: nextFiles.length > 0, + sql: + extensionsRepaired && opts.replannedDiffSql !== undefined + ? opts.replannedDiffSql + : (opts.diffSql ?? nextFiles.map((file) => file.sql).join("\n")), + files: nextFiles, + sourceRef: "migrations", + targetRef: "declarative", + removals: + opts.removals === undefined + ? undefined + : { ...opts.removals, extensions: remainingExtensions }, + }; + }), }), ) : legacyPgDeltaLegacyEngineLayer.pipe(Layer.provide(engineRuntime)); const layer = Layer.mergeAll( out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), telemetry.layer, cache.layer, seam, @@ -371,10 +470,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { runtimeInfo, processControl.layer, alwaysReadyHttpClientLayer, + legacyLocalGatewayHttpClientTestLayer(alwaysReadyHttpClientLayer), dockerRun, ); return { layer, + configProvider, out, child, dbExec, @@ -404,31 +505,38 @@ const flags = ( const failError = (exit: Exit.Exit<unknown, unknown>) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; +const mapPlanFsError = (error: unknown) => + new LegacyPgDeltaEngineError({ + message: `failed to inspect declarative schema fixtures: ${String(error)}`, + cause: error, + }); -const seedDeclarative = (workdir: string) => { - const dir = join(workdir, "supabase", "schemas"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "public.sql"), "create table a();"); -}; +const seedDeclarative = (workdir: string) => + Effect.gen(function* () { + const dir = join(workdir, "supabase", "schemas"); + yield* makeDirectory(dir); + yield* writeText(join(dir, "public.sql"), "create table a();"); + }); -const seedLegacyUuidDeclarative = (workdir: string, directory = "schemas") => { - const dir = join(workdir, "supabase", directory); - mkdirSync(join(dir, "schemas", "app", "tables"), { recursive: true }); - mkdirSync(join(dir, "schemas", "public", "views"), { recursive: true }); - writeFileSync( - join(dir, "schemas", "app", "tables", "members.sql"), - [ - "create table app.members (", - " email text not null,", - " id uuid not null default extensions.uuid_generate_v4()", - ");", - ].join("\n"), - ); - writeFileSync( - join(dir, "schemas", "public", "views", "members.sql"), - "create view public.members as select * from app.members;\n", - ); -}; +const seedLegacyUuidDeclarative = (workdir: string, directory = "schemas") => + Effect.gen(function* () { + const dir = join(workdir, "supabase", directory); + yield* makeDirectory(join(dir, "schemas", "app", "tables")); + yield* makeDirectory(join(dir, "schemas", "public", "views")); + yield* writeText( + join(dir, "schemas", "app", "tables", "members.sql"), + [ + "create table app.members (", + " email text not null,", + " id uuid not null default extensions.uuid_generate_v4()", + ");", + ].join("\n"), + ); + yield* writeText( + join(dir, "schemas", "public", "views", "members.sql"), + "create view public.members as select * from app.members;\n", + ); + }); const legacyUuidLoadError = () => new LegacyPgDeltaEngineError({ @@ -454,9 +562,9 @@ describe("legacy db schema declarative sync integration", () => { const tmp = useLegacyTempWorkdir(); it.effect("gate: fails when pg-delta is not enabled", () => { - seedDeclarative(tmp.current); const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); }).pipe(Effect.provide(layer)); @@ -509,18 +617,16 @@ describe("legacy db schema declarative sync integration", () => { // so an env-only experimental session still opens the gate and lets the mutex // check fire. legacyResolveExperimental (not the raw LegacyExperimentalFlag) is // what makes the TS gate honor the env var the same way. - const { layer } = setup(tmp.current, { experimental: false }); - const ENV = "SUPABASE_EXPERIMENTAL"; + const { layer } = setup(tmp.current, { + experimental: false, + env: { SUPABASE_EXPERIMENTAL: "1" }, + }); return Effect.gen(function* () { - const saved = process.env[ENV]; - process.env[ENV] = "1"; const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( flags({ apply: Option.some(true), noApply: Option.some(true) }), ), ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ _tag: "LegacyDeclarativeMutuallyExclusiveFlagsError", @@ -541,14 +647,10 @@ describe("legacy db schema declarative sync integration", () => { const { layer } = setup(tmp.current, { experimental: false, args: ["db", "schema", "declarative", "sync", "--experimental=false"], + env: { SUPABASE_EXPERIMENTAL: "1" }, }); - const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { - const saved = process.env[ENV]; - process.env[ENV] = "1"; const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeNotEnabledError"); }).pipe(Effect.provide(layer)); @@ -564,16 +666,14 @@ describe("legacy db schema declarative sync integration", () => { // present at commit 7b469f5b3; pkg/config/config.go:789), so a // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex // check fire, same as the shell-env case above. - const saved = process.env["SUPABASE_EXPERIMENTAL"]; - delete process.env["SUPABASE_EXPERIMENTAL"]; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); - const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeText(join(tmp.current, "supabase", ".env"), "SUPABASE_EXPERIMENTAL=true\n"); + const { layer } = setup(tmp.current, { experimental: false }); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( flags({ apply: Option.some(true), noApply: Option.some(true) }), - ), + ).pipe(Effect.provide(layer)), ); expect(Exit.isFailure(exit)).toBe(true); expect(failError(exit)).toMatchObject({ @@ -581,15 +681,7 @@ describe("legacy db schema declarative sync integration", () => { message: "if any flags in the group [apply no-apply] are set none of the others can be; [apply no-apply] were all set", }); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL"]; - else process.env["SUPABASE_EXPERIMENTAL"] = saved; - }), - ), - ); + }); }, ); @@ -630,11 +722,11 @@ describe("legacy db schema declarative sync integration", () => { // supabase/schemas. A project that generated under the old default and never // set declarative_schema_path must get an explanation, not a bare // "no declarative schema found". - const formerDir = join(tmp.current, "supabase", "database"); - mkdirSync(formerDir, { recursive: true }); - writeFileSync(join(formerDir, "public.sql"), "create table a();"); const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { + const formerDir = join(tmp.current, "supabase", "database"); + yield* makeDirectory(formerDir); + yield* writeText(join(formerDir, "public.sql"), "create table a();"); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); expect(Exit.isFailure(exit)).toBe(true); expect(stripAnsi(s.out.stderrText)).toContain( @@ -645,15 +737,15 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("non-interactive default dry-run does not check the local Postgres image", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, staleLocalImage: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags()); - const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + const migrations = yield* readDirectory(join(tmp.current, "supabase", "migrations")); expect(migrations).toHaveLength(1); expect(s.localPostgresImageChecks).toEqual([]); expect(s.dbExec).toEqual([]); @@ -661,13 +753,13 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("--apply checks the local Postgres image before applying", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, staleLocalImage: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), ); @@ -682,15 +774,15 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("--no-apply skips the local Postgres image check", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, staleLocalImage: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + const migrations = yield* readDirectory(join(tmp.current, "supabase", "migrations")); expect(migrations).toHaveLength(1); expect(s.localPostgresImageChecks).toEqual([]); expect(s.dbExec).toEqual([]); @@ -707,7 +799,7 @@ describe("legacy db schema declarative sync integration", () => { const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); - expect(JSON.stringify(exit)).not.toContain("no declarative schema found"); + expect(Formatter.formatJson(exit)).not.toContain("no declarative schema found"); }).pipe(Effect.provide(s.layer)); }); @@ -748,7 +840,7 @@ describe("legacy db schema declarative sync integration", () => { expect(diffStartIndex).toBeGreaterThan(lineAt); // The generated files actually landed in the printed (resolved) dir. expect( - existsSync( + yield* fileExists( join(tmp.current, "supabase", "schemas", "schemas", "public", "tables", "players.sql"), ), ).toBe(true); @@ -813,22 +905,22 @@ describe("legacy db schema declarative sync integration", () => { // `PersistentPreRunE`, strictly before `declarative.go`'s `createShadowContainer` // ever prints "Creating shadow database..." (`declarative.go:490`). So a broken // build must fail here without ever printing that banner. - seedDeclarative(tmp.current); - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[api]", - "enabled = true", - "[api.tls]", - "enabled = true", - 'cert_path = "missing-cert.pem"', - 'key_path = "missing-key.pem"', - "", - ].join("\n"), - ); const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeText( + join(tmp.current, "supabase", "config.toml"), + [ + "[api]", + "enabled = true", + "[api.tls]", + "enabled = true", + 'cert_path = "missing-cert.pem"', + 'key_path = "missing-key.pem"', + "", + ].join("\n"), + ); const exit = yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags())); expect(Exit.isFailure(exit)).toBe(true); expect((failError(exit) as { message: string }).message).toContain( @@ -847,8 +939,6 @@ describe("legacy db schema declarative sync integration", () => { // Go delegates the no-files bootstrap to runDeclarativeGenerate; with migrations // present it offers local/linked/custom rather than silently generating from // local. projectId "test" is an invalid ref so the linked choice is hidden. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -857,6 +947,8 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) }))); const options = s.out.promptSelectCalls[0]?.options ?? []; expect(options.map((o) => o.value)).toEqual(["local", "custom"]); @@ -867,8 +959,6 @@ describe("legacy db schema declarative sync integration", () => { // The stale-image guard only matters once bootstrap chooses a local source. A // linked/custom bootstrap can build fresh catalogs and skip local apply, so it // must reach the target prompt before any local-container inspection. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -878,11 +968,13 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["linked"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), ); expect(s.localPostgresImageChecks).toEqual([]); - expect(JSON.stringify(exit)).not.toContain("local Postgres container image is stale"); + expect(Formatter.formatJson(exit)).not.toContain("local Postgres container image is stale"); expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ "local", "linked", @@ -892,8 +984,6 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("bootstrap linked target checks the local Postgres image before apply", () => { - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -905,6 +995,8 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["linked"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync( flags({ @@ -925,8 +1017,6 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("bootstrap local target checks the local Postgres image", () => { - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -935,6 +1025,8 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noCache: true, noApply: Option.some(true) })), ); @@ -952,8 +1044,6 @@ describe("legacy db schema declarative sync integration", () => { // (db_schema_declarative.go:164-169), flowing into the no-migrations local generate. // Seeding supabase/migrations as a FILE makes the probe's list fail with ENOTDIR; it // must be swallowed so the bootstrap reaches generation, not abort on the read. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -961,12 +1051,14 @@ describe("legacy db schema declarative sync integration", () => { promptConfirmResponses: [true], // generate a new one? yes (no reset prompt: no migrations) }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase")); + yield* writeText(join(tmp.current, "supabase", "migrations"), "not a directory"); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); // The probe was softened: it reached generation and failed downstream on the // empty edge-runtime output, NOT on the migrations directory read. - const msg = JSON.stringify(exit); + const msg = Formatter.formatJson(exit); expect(msg).not.toContain("failed to read directory"); expect(msg).toContain("edge-runtime script produced no output"); }).pipe(Effect.provide(s.layer)); @@ -977,9 +1069,6 @@ describe("legacy db schema declarative sync integration", () => { // db_schema_declarative.go:222-224): a broken .temp/project-ref omits the linked // choice and bootstrap continues. Seeding project-ref as a DIRECTORY makes the read // fail; the bootstrap smart read must swallow it, not abort. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); - mkdirSync(join(tmp.current, "supabase", ".temp", "project-ref"), { recursive: true }); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -989,6 +1078,9 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); + yield* makeDirectory(join(tmp.current, "supabase", ".temp", "project-ref")); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); @@ -997,7 +1089,7 @@ describe("legacy db schema declarative sync integration", () => { "local", "custom", ]); - expect(JSON.stringify(exit)).not.toContain("failed to load project ref"); + expect(Formatter.formatJson(exit)).not.toContain("failed to load project ref"); }).pipe(Effect.provide(s.layer)); }); @@ -1007,8 +1099,6 @@ describe("legacy db schema declarative sync integration", () => { // writes the linked-project cache on success OR failure (cmd/root.go:176,214-218). // Here the bootstrap resolves the linked ref then fails (empty generate output), // and the linked-project cache must still be written. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1018,6 +1108,8 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) }))); expect(s.cache.cached).toBe(true); }).pipe(Effect.provide(s.layer)); @@ -1026,8 +1118,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("does not cache when the workdir is not linked", () => { // No project_id and no .temp/project-ref file → no ref resolves in the bootstrap, // so flags.ProjectRef stays empty in Go and nothing is cached. - mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -1037,32 +1127,34 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["local"], }); return Effect.gen(function* () { + yield* makeDirectory(join(tmp.current, "supabase", "migrations")); + yield* writeText(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); yield* Effect.exit(legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) }))); expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); }); it.effect("empty diff prints 'No schema changes found' and writes nothing", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, diffSql: "" }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); expect(s.out.rawChunks.some((c) => c.text.includes("No schema changes found"))).toBe(true); - expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); }); it.effect( "--no-apply: writes the timestamped migration, surfaces drop warnings, no apply", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\nDROP TABLE c;\n", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + const migrations = yield* readDirectory(join(tmp.current, "supabase", "migrations")); expect(migrations).toHaveLength(1); expect(migrations[0]).toMatch(/^\d{14}_declarative_sync\.sql$/); expect(s.out.rawChunks.some((c) => c.text.includes("Found drop statements"))).toBe(true); @@ -1072,12 +1164,12 @@ describe("legacy db schema declarative sync integration", () => { ); it.effect("--apply: batches the migration and history through the native session", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); expect(s.dbBatches).toContainEqual([ "ALTER TABLE a ADD COLUMN b int", @@ -1092,13 +1184,13 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("refuses a known implicit-extension load failure under --yes", () => { - seedLegacyUuidDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", yes: true, planErrors: [legacyUuidLoadError()], }); return Effect.gen(function* () { + yield* seedLegacyUuidDeclarative(tmp.current); const exit = yield* legacyDbSchemaDeclarativeSync(flags()).pipe(Effect.exit); expect(failError(exit)).toMatchObject({ _tag: "LegacyDeclarativeCompatibilityError", @@ -1115,13 +1207,12 @@ describe("legacy db schema declarative sync integration", () => { }); // Hand-editing extension.sql is a false trail non-interactively: each // declaration only unlocks the next refusal. - expect(JSON.stringify(error)).not.toContain("extension.sql"); - expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(Formatter.formatJson(error)).not.toContain("extension.sql"); + expect(yield* fileExists(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); }); it.effect("adds a missing load-time extension declaration and re-plans", () => { - seedLegacyUuidDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1129,16 +1220,16 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["repair"], }); return Effect.gen(function* () { + yield* seedLegacyUuidDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); expect(s.planCalls).toBe(2); - expect(readFileSync(join(tmp.current, "supabase", "schemas", "extension.sql"), "utf8")).toBe( + expect(yield* readText(join(tmp.current, "supabase", "schemas", "extension.sql"))).toBe( 'CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions";\n', ); }).pipe(Effect.provide(s.layer)); }); it.effect("stages a complete next export without changing the active tree", () => { - seedLegacyUuidDeclarative(tmp.current); const activeMember = join( tmp.current, "supabase", @@ -1148,7 +1239,6 @@ describe("legacy db schema declarative sync integration", () => { "tables", "members.sql", ); - const before = readFileSync(activeMember, "utf8"); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1157,31 +1247,22 @@ describe("legacy db schema declarative sync integration", () => { promptConfirmResponses: [false], // decline the staged export's reset offer }); return Effect.gen(function* () { + yield* seedLegacyUuidDeclarative(tmp.current); + const before = yield* readText(activeMember); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect(readFileSync(activeMember, "utf8")).toBe(before); + expect(yield* readText(activeMember)).toBe(before); expect( - readFileSync( + yield* readText( join(tmp.current, "supabase", "schemas-next", "public", "tables", "players.sql"), - "utf8", ), ).toBe("create table players ();"); expect( - existsSync(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), + yield* fileExists(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), ).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect("stages beside a custom active path and preserves --schema for adoption", () => { - seedLegacyUuidDeclarative(tmp.current, "custom-declarative"); - writeFileSync( - join(tmp.current, "supabase", "config.toml"), - [ - "[experimental.pgdelta]", - "enabled = true", - 'declarative_schema_path = "./custom-declarative"', - "", - ].join("\n"), - ); const activeMember = join( tmp.current, "supabase", @@ -1191,7 +1272,6 @@ describe("legacy db schema declarative sync integration", () => { "tables", "members.sql", ); - const before = readFileSync(activeMember, "utf8"); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1201,11 +1281,22 @@ describe("legacy db schema declarative sync integration", () => { }); return Effect.gen(function* () { + yield* seedLegacyUuidDeclarative(tmp.current, "custom-declarative"); + yield* writeText( + join(tmp.current, "supabase", "config.toml"), + [ + "[experimental.pgdelta]", + "enabled = true", + 'declarative_schema_path = "./custom-declarative"', + "", + ].join("\n"), + ); + const before = yield* readText(activeMember); yield* legacyDbSchemaDeclarativeSync(flags({ schema: ["app"], noApply: Option.some(true) })); - expect(readFileSync(activeMember, "utf8")).toBe(before); + expect(yield* readText(activeMember)).toBe(before); expect( - existsSync( + yield* fileExists( join(tmp.current, "supabase", "custom-declarative-next", ".pgdelta-export.json"), ), ).toBe(true); @@ -1220,7 +1311,6 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("refuses extension-managed legacy gaps under --yes instead of writing drops", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, engineImplementation: "next", @@ -1235,6 +1325,7 @@ describe("legacy db schema declarative sync integration", () => { }, }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); const exit = yield* legacyDbSchemaDeclarativeSync(flags()).pipe(Effect.exit); expect(failError(exit)).toMatchObject({ _tag: "LegacyDeclarativeCompatibilityError", @@ -1249,12 +1340,11 @@ describe("legacy db schema declarative sync integration", () => { expect(failError(exit)).toMatchObject({ message: expect.stringContaining(" Extension-managed objects: pg_cron job refresh"), }); - expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); }); it.effect("directs pg_net users to enable Database Webhooks before writing", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1262,6 +1352,7 @@ describe("legacy db schema declarative sync integration", () => { removals: { extensions: ["pg_net"], extensionIntents: [] }, }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); const exit = yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })).pipe( Effect.exit, ); @@ -1269,14 +1360,13 @@ describe("legacy db schema declarative sync integration", () => { _tag: "LegacyDeclarativeCompatibilityError", message: expect.stringContaining("[experimental.webhooks]\nenabled = true"), }); - expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); }); it.effect( "continues with intentional legacy extension removals only after explicit choice", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1285,14 +1375,14 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["continue"], }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect(readdirSync(join(tmp.current, "supabase", "migrations"))).toHaveLength(1); + expect(yield* readDirectory(join(tmp.current, "supabase", "migrations"))).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }, ); it.effect("repairs the active tree in place when the user picks the advanced choice", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1302,17 +1392,17 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["repair"], }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect(readFileSync(join(tmp.current, "supabase", "schemas", "extension.sql"), "utf8")).toBe( + expect(yield* readText(join(tmp.current, "supabase", "schemas", "extension.sql"))).toBe( 'CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions";\n', ); expect(s.planCalls).toBe(2); - expect(readdirSync(join(tmp.current, "supabase", "migrations"))).toHaveLength(1); + expect(yield* readDirectory(join(tmp.current, "supabase", "migrations"))).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); it.effect("stages a next export from the repair prompt without touching the tree", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1322,12 +1412,15 @@ describe("legacy db schema declarative sync integration", () => { promptConfirmResponses: [false], // decline the staged export's reset offer }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); expect( - existsSync(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), + yield* fileExists(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), ).toBe(true); - expect(existsSync(join(tmp.current, "supabase", "schemas", "extension.sql"))).toBe(false); - expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "schemas", "extension.sql"))).toBe( + false, + ); + expect(yield* fileExists(join(tmp.current, "supabase", "migrations"))).toBe(false); expect(stripAnsi(s.out.stderrText)).toContain( "rm -rf supabase/schemas && mv supabase/schemas-next supabase/schemas", ); @@ -1335,11 +1428,9 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("staged export names its live-database source and honors the reset offer", () => { - seedDeclarative(tmp.current); // `legacyResetLocalDatabase`'s container-recreate resolves its own project id // from `@supabase/config` — pin it so the recreated container name matches // the spawner route's assumption (same as the apply-failure reset test). - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1349,6 +1440,8 @@ describe("legacy db schema declarative sync integration", () => { promptConfirmResponses: [true], // accept the staged export's reset offer }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); + yield* writeText(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); // The export source is stated before the snapshot, so stale local drift // cannot silently become the staged declarative tree. @@ -1358,13 +1451,12 @@ describe("legacy db schema declarative sync integration", () => { // Accepting the offer really reset the local database before the export. expect(legacyLocalResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); expect( - existsSync(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), + yield* fileExists(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), ).toBe(true); }).pipe(Effect.provide(s.layer)); }); it.effect("cancels compatibility resolution without schema or migration writes", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { engineImplementation: "next", stdinIsTty: true, @@ -1373,18 +1465,16 @@ describe("legacy db schema declarative sync integration", () => { promptSelectResponses: ["cancel"], }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); - expect(existsSync(join(tmp.current, "supabase", "schemas", "extension.sql"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "migrations"))).toBe(false); + expect(yield* fileExists(join(tmp.current, "supabase", "schemas", "extension.sql"))).toBe( + false, + ); }).pipe(Effect.provide(s.layer)); }); it.effect("suppresses the compatibility warning when a next export manifest is present", () => { - seedDeclarative(tmp.current); - writeFileSync( - join(tmp.current, "supabase", "schemas", ".pgdelta-export.json"), - JSON.stringify({ formatVersion: 1, redactSecrets: true, scope: "database" }), - ); const s = setup(tmp.current, { experimental: true, engineImplementation: "next", @@ -1392,6 +1482,11 @@ describe("legacy db schema declarative sync integration", () => { removals: { extensions: ["pgcrypto"], extensionIntents: [] }, }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); + yield* writeText( + join(tmp.current, "supabase", "schemas", ".pgdelta-export.json"), + encodeManifestJson({ formatVersion: 1, redactSecrets: true, scope: "database" }), + ); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); const output = stripAnsi(s.out.rawChunks.map((chunk) => chunk.text).join("")); expect(output).not.toContain("may have been generated by the legacy engine"); @@ -1400,16 +1495,16 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("--name overrides the migration filename stem", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync( flags({ noApply: Option.some(true), name: Option.some("add_b") }), ); - const migrations = readdirSync(join(tmp.current, "supabase", "migrations")); + const migrations = yield* readDirectory(join(tmp.current, "supabase", "migrations")); expect(migrations[0]).toMatch(/^\d{14}_add_b\.sql$/); }).pipe(Effect.provide(s.layer)); }); @@ -1417,12 +1512,10 @@ describe("legacy db schema declarative sync integration", () => { it.effect( "apply failure in a TTY offers reset+reapply and runs the reset natively in-process", () => { - seedDeclarative(tmp.current); // `legacyResetLocalDatabase`'s container-recreate resolves its own project id // from `@supabase/config` (config.toml / real env), independently of the // mocked `LegacyCliConfig.projectId` — pin it to "test" so the recreated // container name matches the spawner route's assumption. - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", @@ -1431,6 +1524,8 @@ describe("legacy db schema declarative sync integration", () => { promptConfirmResponses: [true], // accept the reset offer }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); + yield* writeText(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); expect(s.out.rawChunks.some((c) => c.text.includes("Migration failed to apply"))).toBe( true, @@ -1446,7 +1541,9 @@ describe("legacy db schema declarative sync integration", () => { c.text.includes("Database reset and all migrations applied successfully"), ), ).toBe(true); - expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta", "debug"))).toBe(true); + expect(yield* fileExists(join(tmp.current, "supabase", ".temp", "pgdelta", "debug"))).toBe( + true, + ); // `legacyResetLocalDatabase`'s own body never touches telemetry — the outer // `sync` command's single `Effect.ensuring` finalizer must still fire // EXACTLY once, not twice, matching Go's single-process `reset.Run` (no @@ -1459,7 +1556,6 @@ describe("legacy db schema declarative sync integration", () => { it.effect("surfaces the reset failure (not the apply error) when reset also fails", () => { // Go returns resetErr here (`cmd/db_schema_declarative.go:414-423`), so the failure // that actually blocked recovery is reported, not the original apply error ("boom"). - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", @@ -1469,6 +1565,7 @@ describe("legacy db schema declarative sync integration", () => { resetShouldFail: true, // …and the reset itself fails (local db not running) }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); const exit = yield* Effect.exit( legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })), ); @@ -1491,8 +1588,6 @@ describe("legacy db schema declarative sync integration", () => { // `legacyResetLocalDatabase` resolves `LegacyNetworkIdFlag` itself from the // shared context (CLI-2062) — no argv-forwarding needed — so the recreated // container must land on the custom network directly. - seedDeclarative(tmp.current); - writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, diffSql: "ALTER TABLE a ADD COLUMN b int;\n", @@ -1502,6 +1597,8 @@ describe("legacy db schema declarative sync integration", () => { networkId: "my_net", }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); + yield* writeText(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); yield* legacyDbSchemaDeclarativeSync(flags({ apply: Option.some(true) })); const createArgs = legacyLocalResetCreateArgs(s.child.spawned); const networkIndex = createArgs?.indexOf("--network") ?? -1; @@ -1511,7 +1608,6 @@ describe("legacy db schema declarative sync integration", () => { }); it.effect("next engine preserves ordered migration segments as separate files", () => { - seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, engineImplementation: "next", @@ -1533,8 +1629,9 @@ describe("legacy db schema declarative sync integration", () => { ], }); return Effect.gen(function* () { + yield* seedDeclarative(tmp.current); yield* legacyDbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - const migrations = readdirSync(join(tmp.current, "supabase", "migrations")).sort(); + const migrations = (yield* readDirectory(join(tmp.current, "supabase", "migrations"))).sort(); expect(migrations).toHaveLength(2); expect(migrations[0]).toMatch(/^\d{14}_declarative_sync_1\.sql$/); expect(migrations[1]).toMatch(/^\d{14}_declarative_sync_2\.sql$/); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts index f3b08a22dc..c3d900a676 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.ts @@ -1,4 +1,4 @@ -import { Effect, type FileSystem, type Path } from "effect"; +import { DateTime, Effect, type FileSystem, type Path } from "effect"; import { legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; import { legacyListLocalMigrations } from "../../../shared/legacy-pgdelta.cache.ts"; @@ -30,7 +30,7 @@ export interface LegacyDebugBundle { /** Go's debug-bundle id layout `20060102-150405` (UTC). */ export function legacyFormatDebugId(millis: number): string { - const digits = new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); + const digits = DateTime.formatIso(DateTime.makeUnsafe(millis)).replace(/\D/gu, "").slice(0, 14); return `${digits.slice(0, 8)}-${digits.slice(8)}`; } diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts index b15c6612d2..4b2ca873e2 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-debug-bundle.unit.test.ts @@ -1,6 +1,3 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Path } from "effect"; @@ -8,91 +5,75 @@ import { Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyCollectMigrationsList, legacySaveDebugBundle } from "./legacy-debug-bundle.ts"; -const save = (workdir: string, tempDir: string, migrationsDir: string, id: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacySaveDebugBundle(fs, path, workdir, tempDir, migrationsDir, { - id, - error: "boom", - migrationSql: "create table t();", - }); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); - describe("legacySaveDebugBundle", () => { - it.effect("writes artifacts and returns the debug directory", () => { - const root = mkdtempSync(join(tmpdir(), "legacy-debug-")); - const tempDir = join(root, "supabase", ".temp", "pgdelta"); - return save(root, tempDir, join(root, "supabase", "migrations"), "20240101-000000").pipe( - Effect.tap((debugDir) => - Effect.sync(() => { - expect(debugDir).toBe(join(tempDir, "debug", "20240101-000000")); - expect(existsSync(join(debugDir, "generated-migration.sql"))).toBe(true); - expect(readFileSync(join(debugDir, "error.txt"), "utf8")).toBe("boom"); - rmSync(root, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("writes artifacts and returns the debug directory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-debug-" }); + const tempDir = path.join(root, "supabase", ".temp", "pgdelta"); + const debugDir = yield* legacySaveDebugBundle( + fs, + path, + root, + tempDir, + path.join(root, "supabase", "migrations"), + { id: "20240101-000000", error: "boom", migrationSql: "create table t();" }, + ); + expect(debugDir).toBe(path.join(tempDir, "debug", "20240101-000000")); + expect(yield* fs.exists(path.join(debugDir, "generated-migration.sql"))).toBe(true); + expect(yield* fs.readFileString(path.join(debugDir, "error.txt"))).toBe("boom"); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))), + ); - it.effect("fails (does not return a path) when the debug directory cannot be created", () => { - // Plant a regular file where the `debug` directory needs to be, so the recursive - // makeDirectory fails — Go's SaveDebugBundle returns an error here rather than - // claiming a bundle was saved. - const root = mkdtempSync(join(tmpdir(), "legacy-debug-fail-")); - const tempDir = join(root, "pgdelta"); - writeFileSync(join(root, "pgdelta"), "not a directory"); - return save(root, tempDir, join(root, "migrations"), "20240101-000000").pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(root, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("fails (does not return a path) when the debug directory cannot be created", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-debug-fail-" }); + const tempDir = path.join(root, "pgdelta"); + yield* fs.writeFileString(tempDir, "not a directory"); + const exit = yield* legacySaveDebugBundle( + fs, + path, + root, + tempDir, + path.join(root, "migrations"), + { id: "20240101-000000", error: "boom", migrationSql: "create table t();" }, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))), + ); }); -const collect = (migrationsDir: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyCollectMigrationsList(fs, path, migrationsDir); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); - describe("legacyCollectMigrationsList", () => { - it.effect("returns migration filenames when the dir is readable", () => { - const root = mkdtempSync(join(tmpdir(), "legacy-collect-")); - const migrationsDir = join(root, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - return collect(migrationsDir).pipe( - Effect.tap((names) => - Effect.sync(() => { - expect(names).toEqual(["20240101120000_create.sql"]); - rmSync(root, { recursive: true, force: true }); - }), - ), - ); - }); + it.effect("returns migration filenames when the dir is readable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-collect-" }); + const migrationsDir = path.join(root, "supabase", "migrations"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + yield* fs.writeFileString( + path.join(migrationsDir, "20240101120000_create.sql"), + "create table x();", + ); + expect(yield* legacyCollectMigrationsList(fs, path, migrationsDir)).toEqual([ + "20240101120000_create.sql", + ]); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))), + ); it.effect( "swallows an unreadable migrations dir (returns []) so it never masks the primary error", - () => { - // Go's CollectMigrationsList returns nil on a read error; the debug bundle just - // omits migration copies rather than replacing the in-flight diff/apply error. - const root = mkdtempSync(join(tmpdir(), "legacy-collect-fail-")); - const migrationsPath = join(root, "migrations"); - writeFileSync(migrationsPath, "not a directory"); - return collect(migrationsPath).pipe( - Effect.tap((names) => - Effect.sync(() => { - expect(names).toEqual([]); - rmSync(root, { recursive: true, force: true }); - }), - ), - ); - }, + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-collect-fail-" }); + const migrationsPath = path.join(root, "migrations"); + yield* fs.writeFileString(migrationsPath, "not a directory"); + expect(yield* legacyCollectMigrationsList(fs, path, migrationsPath)).toEqual([]); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))), ); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts index f6e758efcb..7307993fcf 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.deno-templates.unit.test.ts @@ -1,6 +1,7 @@ -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL } from "../../../shared/legacy-edge-runtime-script.service.ts"; import { @@ -13,13 +14,20 @@ import { const goDiffTemplatesDir = fileURLToPath( new URL("../../../../../../cli-go/internal/db/diff/templates/", import.meta.url), ); -const readGoTemplate = (name: string) => readFileSync(`${goDiffTemplatesDir}${name}`, "utf8"); +const readGoTemplate = (name: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(goDiffTemplatesDir, name)); + }); describe("embedded migra templates", () => { - it("match the Go sources byte-for-byte", () => { - expect(legacyMigraDiffScript).toBe(readGoTemplate("migra.ts")); - expect(legacyMigraDiffShellScript).toBe(readGoTemplate("migra.sh")); - }); + it.effect("match the Go sources byte-for-byte", () => + Effect.gen(function* () { + expect(legacyMigraDiffScript).toBe(yield* readGoTemplate("migra.ts")); + expect(legacyMigraDiffShellScript).toBe(yield* readGoTemplate("migra.sh")); + }).pipe(Effect.provide(BunServices.layer)), + ); it("emit the error sentinel from the diff script's failure path", () => { expect(legacyMigraDiffScript).toContain(LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts index 55fae1c7e1..d5893b76de 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.ts @@ -1,6 +1,7 @@ import { Effect, Option } from "effect"; import { LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { LegacyDbConnection, @@ -97,8 +98,8 @@ where pd.deptype is null order by pn.nspname`; /** Mirrors Go's `types.IsSSLDebugEnabled` (`internal/gen/types/types.go:201`). */ -function legacyIsSslDebugEnabled(): boolean { - return (process.env["SUPABASE_SSL_DEBUG"] ?? "").toLowerCase() === "true"; +function legacyIsSslDebugEnabled(value: string | undefined): boolean { + return (value ?? "").toLowerCase() === "true"; } /** Mirrors Go's `shouldFallbackToLegacyMigra` (`internal/db/diff/migra.go:155`). */ @@ -116,11 +117,17 @@ const buildMigraEnv = Effect.fnUntraced(function* (params: { readonly schema: ReadonlyArray<string>; }) { const probe = yield* LegacyPgDeltaSslProbe; + const legacyEnv = yield* LegacyViperEnv; + const sslDebug = yield* legacyEnv + .get("SUPABASE_SSL_DEBUG") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); const env: Record<string, string> = { SOURCE: params.source, TARGET: params.target, }; - if (legacyIsSslDebugEnabled()) env["SUPABASE_SSL_DEBUG"] = "true"; + if (legacyIsSslDebugEnabled(Option.getOrUndefined(sslDebug))) { + env["SUPABASE_SSL_DEBUG"] = "true"; + } // Go's GetRootCA: probe the target for TLS; if it speaks TLS, inject the // embedded CA bundle as SSL_CA (`internal/gen/types/types.go:124-148`). const requireSsl = yield* probe.requireSsl(params.target); @@ -146,11 +153,9 @@ const loadTargetUserSchemas = Effect.fnUntraced(function* ( const connection = yield* LegacyDbConnection; const input = parseLegacyConnectionString(target); if (input === undefined) { - return yield* Effect.fail( - new LegacyMigraSchemaLoadError({ - message: "failed to list schemas: invalid target connection string", - }), - ); + return yield* new LegacyMigraSchemaLoadError({ + message: "failed to list schemas: invalid target connection string", + }); } return yield* Effect.scoped( Effect.gen(function* () { @@ -191,13 +196,19 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { }) { const docker = yield* LegacyDockerRun; const runtimeInfo = yield* RuntimeInfo; + const legacyEnv = yield* LegacyViperEnv; const networkIdFlag = yield* LegacyNetworkIdFlag; const schema = params.schema.length > 0 ? params.schema : yield* loadTargetUserSchemas(params.target, params.connectOptions); const env: Record<string, string> = { SOURCE: params.source, TARGET: params.target }; - if (legacyIsSslDebugEnabled()) env["SUPABASE_SSL_DEBUG"] = "true"; + const sslDebug = yield* legacyEnv + .get("SUPABASE_SSL_DEBUG") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); + if (legacyIsSslDebugEnabled(Option.getOrUndefined(sslDebug))) { + env["SUPABASE_SSL_DEBUG"] = "true"; + } // Passing the script as a string means command-line args must be set manually // via `set --` so migra.sh's `"$@"` loop sees the schema list (Go's `args`). const args = `set -- ${schema.join(" ")};`; @@ -212,9 +223,14 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { ? { _tag: "named" as const, name: networkId } : { _tag: "host" as const }; const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + const registryOverride = yield* legacyEnv + .get("SUPABASE_INTERNAL_IMAGE_REGISTRY") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); const result = yield* docker .runCapture({ - image: legacyGetRegistryImageUrl(LEGACY_MIGRA_IMAGE), + image: legacyGetRegistryImageUrl(LEGACY_MIGRA_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: Option.getOrElse(registryOverride, () => ""), + }), cmd: ["/bin/sh", "-c", args + legacyMigraDiffShellScript], env, binds: [], @@ -236,11 +252,9 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { ), ); if (result.exitCode !== 0) { - return yield* Effect.fail( - new LegacyMigraDiffError({ - message: `error diffing schema:\n${result.stderr}`, - }), - ); + return yield* new LegacyMigraDiffError({ + message: `error diffing schema:\n${result.stderr}`, + }); } return new TextDecoder().decode(result.stdout); }); @@ -271,6 +285,7 @@ export const legacyDiffMigra = Effect.fnUntraced(function* ( env, binds: [`${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`], errPrefix: "error diffing schema", + projectEnvValues: ctx.projectEnv, denoVersion: ctx.denoVersion, workdir: ctx.cwd, }) diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts index 1bb994f675..524dfc76c4 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Config, Effect, FileSystem, Layer, Option, Path } from "effect"; import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; @@ -60,8 +60,9 @@ export const legacyPgDeltaEngineLayer = Layer.unwrap( const path = yield* Path.Path; const cliConfig = yield* LegacyCliConfig; const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const shellFlag = yield* Config.option(Config.string(LEGACY_PG_DELTA_NEXT_FLAG_NAME)); const raw = legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], + Option.getOrUndefined(shellFlag), projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], ); return legacyPgDeltaEngineSelectorLayer(raw, { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts index a0878b34a8..c7ef0a0742 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.legacy.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Crypto, Effect, FileSystem, Layer, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -35,6 +35,7 @@ import { legacyResolveMigrationsCatalogRef, } from "../../../shared/legacy-pgdelta.cache.ts"; import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; const mapError = (cause: { readonly message: string }) => new LegacyPgDeltaEngineError({ message: cause.message, cause }); @@ -96,6 +97,8 @@ export const legacyPgDeltaLegacyEngineLayer = Layer.effect( const debugFlag = yield* LegacyDebugFlag; const experimentalFlag = yield* LegacyExperimentalFlag; const networkIdFlag = yield* LegacyNetworkIdFlag; + const viperEnv = yield* LegacyViperEnv; + const crypto = yield* Crypto.Crypto; const runtime = Layer.mergeAll( Layer.succeed(LegacyEdgeRuntimeScript, edgeRuntime), @@ -113,6 +116,8 @@ export const legacyPgDeltaLegacyEngineLayer = Layer.effect( Layer.succeed(LegacyDebugFlag, debugFlag), Layer.succeed(LegacyExperimentalFlag, experimentalFlag), Layer.succeed(LegacyNetworkIdFlag, networkIdFlag), + Layer.succeed(LegacyViperEnv, viperEnv), + Layer.succeed(Crypto.Crypto, crypto), ); const provideRuntime = <Success, Error, Requirements>( @@ -208,12 +213,10 @@ export const legacyPgDeltaLegacyEngineLayer = Layer.effect( Effect.gen(function* () { yield* warnStrictCoverageIgnored(input.strictCoverage); if (input.source === undefined) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "legacy pg-delta declarative export requires an empty shadow database", - cause: "missing declarative export source", - }), - ); + return yield* new LegacyPgDeltaEngineError({ + message: "legacy pg-delta declarative export requires an empty shadow database", + cause: "missing declarative export source", + }); } const result = yield* provideRuntime( legacyDeclarativeExportPgDelta(input.context, { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts index f1cdb92d45..1ec59ddd47 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.ts @@ -114,16 +114,14 @@ export function legacyParsePgDeltaNextEndpoint( if (endpoint.connection !== undefined) return endpoint.connection; const parsed = parseLegacyConnectionString(endpoint.ref, legacyLayeredParseEnv(projectEnv)); if (parsed !== undefined) return parsed; - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "failed to parse Postgres connection string for pg-delta", - // `redactLegacyConnectionString`, not a local `:password@` regex: the input - // reaching here is by definition unparseable, and a hand-typed password - // containing `/`, `@`, or `:` defeats a naive single-character-class match - // (CWE-209). The shared redactor over-redacts instead of leaking. - cause: redactLegacyConnectionString(endpoint.ref), - }), - ); + return yield* new LegacyPgDeltaEngineError({ + message: "failed to parse Postgres connection string for pg-delta", + // `redactLegacyConnectionString`, not a local `:password@` regex: the input + // reaching here is by definition unparseable, and a hand-typed password + // containing `/`, `@`, or `:` defeats a naive single-character-class match + // (CWE-209). The shared redactor over-redacts instead of leaking. + cause: redactLegacyConnectionString(endpoint.ref), + }); }); } @@ -143,7 +141,7 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( workdir: string, operation: LegacyPgDeltaNextOperation, artifacts: LegacyPgDeltaNextDebugArtifacts, - ) => + ): Effect.Effect<string | undefined, never> => Effect.gen(function* () { const id = legacyFormatPgDeltaNextDebugId(yield* Clock.currentTimeMillis, operation); const debugDir = yield* legacySavePgDeltaNextDebugArtifacts( @@ -153,6 +151,9 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( id, operation, artifacts, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), ); yield* debugLogger.debug(`Saved pg-delta next debug artifacts to ${debugDir}.`); return debugDir; @@ -177,7 +178,11 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( projectEnv: Readonly<Record<string, string>>, ) => legacyParsePgDeltaNextEndpoint(endpoint, projectEnv).pipe( - Effect.flatMap((connection) => legacyAcquirePgPool(connection, endpoint.connectOptions)), + Effect.flatMap((connection) => + legacyAcquirePgPool(connection, endpoint.connectOptions).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + ), ); const reportDiagnostics = ( @@ -246,12 +251,10 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( : undefined; if (migrationsEndpoint !== undefined) { if (input.toml === undefined) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "pg-delta migrations endpoint requires loaded database config", - cause: "missing database config", - }), - ); + return yield* new LegacyPgDeltaEngineError({ + message: "pg-delta migrations endpoint requires loaded database config", + cause: "missing database config", + }); } shadow = yield* shadowService.provisionMigrations({ context: input.context, @@ -271,17 +274,15 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( } const connection = parseLegacyConnectionString(shadow.migrationsUrl); if (connection === undefined) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "failed to parse pg-delta migrations shadow URL", - cause: redactLegacyConnectionString(shadow.migrationsUrl), - }), - ); + return yield* new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta migrations shadow URL", + cause: redactLegacyConnectionString(shadow.migrationsUrl), + }); } return yield* legacyAcquirePgPool(connection, { isLocal: true, dnsResolver: "native", - }); + }).pipe(Effect.provideService(FileSystem.FileSystem, fs)); }); const [sourcePool, desiredPool] = yield* Effect.all( [endpointPool(input.source), endpointPool(input.desired)], @@ -339,17 +340,19 @@ export const legacyPgDeltaNextEngineLayer = Layer.effect( const migrations = parseLegacyConnectionString(shadow.migrationsUrl); const declarative = parseLegacyConnectionString(shadow.declarativeUrl); if (migrations === undefined || declarative === undefined) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: "failed to parse pg-delta next shadow database URL", - cause: "invalid password-free shadow output", - }), - ); + return yield* new LegacyPgDeltaEngineError({ + message: "failed to parse pg-delta next shadow database URL", + cause: "invalid password-free shadow output", + }); } const [migrationsPool, declarativePool] = yield* Effect.all( [ - legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }), - legacyAcquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }), + legacyAcquirePgPool(migrations, { isLocal: true, dnsResolver: "native" }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), + legacyAcquirePgPool(declarative, { isLocal: true, dnsResolver: "native" }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + ), ], { concurrency: 2 }, ); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts index efa7f647aa..28ecd3f931 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-engine.next.layer.unit.test.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { LegacyPgDeltaDatabaseEndpoint } from "./legacy-pgdelta-engine.service.ts"; import { legacyParsePgDeltaNextEndpoint } from "./legacy-pgdelta-engine.next.layer.ts"; @@ -68,19 +68,8 @@ describe("legacyParsePgDeltaNextEndpoint", () => { expect(conn?.password).toBe("from-project"); }); - describe("shell env precedence", () => { - const ORIGINAL_PGPASSWORD = process.env.PGPASSWORD; - - beforeEach(() => { - process.env.PGPASSWORD = "from-shell"; - }); - - afterEach(() => { - if (ORIGINAL_PGPASSWORD === undefined) delete process.env.PGPASSWORD; - else process.env.PGPASSWORD = ORIGINAL_PGPASSWORD; - }); - - it("prefers the shell-set PGPASSWORD over the project .env value", () => { + describe("project env values", () => { + it("uses the explicitly supplied PGPASSWORD value", () => { const endpoint = { kind: "database", ref: "postgres://user@host:5432/db", @@ -91,7 +80,7 @@ describe("legacyParsePgDeltaNextEndpoint", () => { legacyParsePgDeltaNextEndpoint(endpoint, { PGPASSWORD: "from-project" }), ); - expect(conn?.password).toBe("from-shell"); + expect(conn?.password).toBe("from-project"); }); }); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts index 91c98882d0..5ccd4cdbf7 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-files.ts @@ -1,4 +1,4 @@ -import { Data, Effect, type FileSystem, type Path } from "effect"; +import { Data, Effect, Schema, type FileSystem, type Path } from "effect"; import { actionability, @@ -48,15 +48,13 @@ export const LegacyReadPgDeltaExportManifest = Effect.fnUntraced(function* ( filesError(`cannot read export manifest ${manifestPath}: ${error.message}`), ), ); - const decoded = yield* Effect.try({ - try: (): unknown => JSON.parse(raw), - catch: (cause) => - filesError( - `malformed export manifest ${manifestPath}: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }); + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(raw).pipe( + Effect.mapError((cause) => + filesError(`malformed export manifest ${manifestPath}: ${String(cause)}`), + ), + ); if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) { - return yield* Effect.fail(filesError(`malformed export manifest ${manifestPath}`)); + return yield* filesError(`malformed export manifest ${manifestPath}`); } const formatVersion = readManifestValue(decoded, "formatVersion"); @@ -67,9 +65,7 @@ export const LegacyReadPgDeltaExportManifest = Effect.fnUntraced(function* ( typeof redactSecrets !== "boolean" || (scope !== "database" && scope !== "cluster") ) { - return yield* Effect.fail( - filesError(`export manifest ${manifestPath} is missing required policy metadata`), - ); + return yield* filesError(`export manifest ${manifestPath} is missing required policy metadata`); } const profile = readManifestValue(decoded, "profile"); @@ -109,7 +105,7 @@ export const LegacyLoadPgDeltaSqlFiles = Effect.fnUntraced(function* ( for (const name of paths) { const normalized = path.normalize(name); if (normalized.startsWith("..") || path.isAbsolute(normalized)) { - return yield* Effect.fail(filesError(`unsafe declarative schema path: ${name}`)); + return yield* filesError(`unsafe declarative schema path: ${name}`); } const full = path.join(directory, name); const sql = yield* fs diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts index 53a5bf6690..d1c4b129ad 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-migrations.write.ts @@ -81,11 +81,9 @@ export const legacyWritePgDeltaMigrations = ( const { workdir, name, files } = opts; for (const file of files) { if (file.transactionMode !== "transactional" && file.transactionMode !== "none") { - return yield* Effect.fail( - new LegacyPgDeltaMigrationWriteError({ - message: `unknown pg-delta transaction mode ${JSON.stringify(file.transactionMode)}`, - }), - ); + return yield* new LegacyPgDeltaMigrationWriteError({ + message: `unknown pg-delta transaction mode ${String(file.transactionMode)}`, + }); } } const single = files.length === 1; @@ -156,11 +154,9 @@ export const legacyWritePgDeltaMigrations = ( } if (!collision) break; if (attempt + 1 >= MAX_VERSION_COLLISION_ATTEMPTS) { - return yield* Effect.fail( - new LegacyPgDeltaMigrationWriteError({ - message: `failed to find a unique migration version after ${MAX_VERSION_COLLISION_ATTEMPTS} attempts`, - }), - ); + return yield* new LegacyPgDeltaMigrationWriteError({ + message: `failed to find a unique migration version after ${MAX_VERSION_COLLISION_ATTEMPTS} attempts`, + }); } baseMillis += 1000; set = buildSet(baseMillis); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts index 7b66c0f06c..ef0d7c328d 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.layer.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- pg-delta exposes Promise-only APIs at this foreign library boundary. import { Effect, Layer } from "effect"; import type { Pool } from "pg"; import { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts index e3c5ab55c7..0f9e048751 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-adapter.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/prefer-schema-over-json -- Promise-only pg-delta fakes and serialized JSON assertions are intentional at this adapter boundary. import { it } from "@effect/vitest"; import { buildFactBase, type Fact, type StableId } from "@supabase/pg-delta/core"; import { renderPlanFiles, ShadowLoadError } from "@supabase/pg-delta/frontends"; diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts index ebff9b0a4a..6018dd2fca 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.ts @@ -1,4 +1,4 @@ -import { Effect, type FileSystem, type Path } from "effect"; +import { DateTime, Effect, Schema, type FileSystem, type Path } from "effect"; import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.cache.ts"; import type { @@ -23,7 +23,7 @@ export function legacyFormatPgDeltaNextDebugId( millis: number, operation: LegacyPgDeltaNextOperation, ): string { - const digits = new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 17); + const digits = DateTime.formatIso(DateTime.makeUnsafe(millis)).replace(/\D/gu, "").slice(0, 17); return `${digits.slice(0, 8)}-${digits.slice(8, 14)}-${digits.slice(14)}-${operation}`; } @@ -62,7 +62,10 @@ export const legacySavePgDeltaNextDebugArtifacts = Effect.fnUntraced(function* ( yield* write("desired-snapshot.json", artifacts.desiredSnapshot); yield* write("plan.json", artifacts.plan); if (artifacts.diagnostics !== undefined) { - yield* write("diagnostics.json", `${JSON.stringify(artifacts.diagnostics, null, 2)}\n`); + const diagnostics = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown, { space: 2 }), + )(artifacts.diagnostics); + yield* write("diagnostics.json", `${diagnostics}\n`); } const metadata: LegacyPgDeltaNextArtifactMetadata = { @@ -73,9 +76,9 @@ export const legacySavePgDeltaNextDebugArtifacts = Effect.fnUntraced(function* ( cacheReusable: false, files: [...files].sort(), }; - yield* fs.writeFileString( - path.join(debugDir, "metadata.json"), - `${JSON.stringify(metadata, null, 2)}\n`, - ); + const encodedMetadata = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown, { space: 2 }), + )(metadata); + yield* fs.writeFileString(path.join(debugDir, "metadata.json"), `${encodedMetadata}\n`); return debugDir; }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts index 0c58965625..0310c152d2 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-artifacts.unit.test.ts @@ -1,9 +1,6 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { legacyFormatPgDeltaNextDebugId, @@ -14,10 +11,10 @@ import { legacyPgDeltaTempPath } from "../../../shared/legacy-pgdelta.cache.ts"; describe("pg-delta next artifact generation", () => { it.effect("writes structured non-cache artifacts and metadata under v2", () => { - const root = mkdtempSync(join(tmpdir(), "pgdelta-next-artifacts-")); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "pgdelta-next-artifacts-" }); const debugId = legacyFormatPgDeltaNextDebugId(Date.UTC(2024, 0, 2, 3, 4, 5, 678), "diff"); const debugDir = yield* legacySavePgDeltaNextDebugArtifacts(fs, path, root, debugId, "diff", { sourceSnapshot: '{"source":true}\n', @@ -28,8 +25,14 @@ describe("pg-delta next artifact generation", () => { expect(debugId).toBe("20240102-030405-678-diff"); expect(legacyPgDeltaNextTempPath(path, root)).not.toBe(legacyPgDeltaTempPath(path, root)); - expect(debugDir).toBe(join(legacyPgDeltaNextTempPath(path, root), "debug", debugId)); - expect(JSON.parse(readFileSync(join(debugDir, "metadata.json"), "utf8"))).toEqual({ + expect(debugDir).toBe(path.join(legacyPgDeltaNextTempPath(path, root), "debug", debugId)); + const metadata = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(path.join(debugDir, "metadata.json")), + ); + const diagnostics = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(path.join(debugDir, "diagnostics.json")), + ); + expect(metadata).toEqual({ version: 1, generation: "v2", implementation: "next", @@ -37,12 +40,9 @@ describe("pg-delta next artifact generation", () => { cacheReusable: false, files: ["desired-snapshot.json", "diagnostics.json", "plan.json", "source-snapshot.json"], }); - expect(JSON.parse(readFileSync(join(debugDir, "diagnostics.json"), "utf8"))).toEqual([ + expect(diagnostics).toEqual([ { origin: "source", code: "PG001", severity: "warning", message: "warning" }, ]); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(root, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts index 576ca31bb2..ca6946a438 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.ts @@ -216,14 +216,12 @@ export const legacyReportPgDeltaNextDiagnostics = Effect.fnUntraced(function* ( if (feedback !== undefined) yield* output.info(feedback); if (report.blocking.length > 0) { - return yield* Effect.fail( - new LegacyPgDeltaEngineError({ - message: legacyPgDeltaNextBlockingDiagnosticMessage( - operation, - strictCoverage && report.coverage.length > 0, - ), - cause: report.blocking, - }), - ); + return yield* new LegacyPgDeltaEngineError({ + message: legacyPgDeltaNextBlockingDiagnosticMessage( + operation, + strictCoverage && report.coverage.length > 0, + ), + cause: report.blocking, + }); } }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts index a0a11f3be0..da0c0ac353 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-diagnostics.unit.test.ts @@ -33,7 +33,7 @@ const debugLayer = (messages: string[]) => }); describe("pg-delta next diagnostic coverage policy", () => { - it("summarizes unmodeled kinds and routes nonfatal diagnostic detail to debug", () => { + it.effect("summarizes unmodeled kinds and routes nonfatal diagnostic detail to debug", () => { const out = mockOutput(); const debugMessages: string[] = []; return Effect.gen(function* () { @@ -69,10 +69,10 @@ describe("pg-delta next diagnostic coverage policy", () => { ); expect(invitations).toHaveLength(1); expect(invitations[0]?.message).toContain("statistics object, text search configuration"); - }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, debugLayer(debugMessages)))); }); - it("renders coverage diagnostics and then fails in strict mode", () => { + it.effect("renders coverage diagnostics and then fails in strict mode", () => { const out = mockOutput(); const debugMessages: string[] = []; return Effect.gen(function* () { @@ -97,7 +97,7 @@ describe("pg-delta next diagnostic coverage policy", () => { expect(out.messages.some(({ message }) => message.includes("supabase issue feature"))).toBe( true, ); - }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, debugLayer(debugMessages)))); }); const skippedStatement = (file: string, statement: string): LegacyPgDeltaNextDiagnostic => ({ @@ -109,7 +109,7 @@ describe("pg-delta next diagnostic coverage policy", () => { context: { file, statement }, }); - it("warns about skipped declarative statements without leaking their SQL", () => { + it.effect("warns about skipped declarative statements without leaking their SQL", () => { const out = mockOutput(); const debugMessages: string[] = []; return Effect.gen(function* () { @@ -129,10 +129,10 @@ describe("pg-delta next diagnostic coverage policy", () => { }); expect(out.messages.some(({ message }) => message.includes("s3cret"))).toBe(false); expect(debugMessages.some((message) => message.includes("s3cret"))).toBe(true); - }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, debugLayer(debugMessages)))); }); - it("always renders and fails error diagnostics", () => { + it.effect("always renders and fails error diagnostics", () => { const out = mockOutput(); const debugMessages: string[] = []; return Effect.gen(function* () { @@ -155,10 +155,10 @@ describe("pg-delta next diagnostic coverage policy", () => { message: "pg-delta next diagnostic: origin=export code=extraction_failed message=catalog query failed", }); - }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, debugLayer(debugMessages)))); }); - it("renders every diagnostic with full detail when pg-delta debug is enabled", () => { + it.effect("renders every diagnostic with full detail when pg-delta debug is enabled", () => { const out = mockOutput(); const debugMessages: string[] = []; return Effect.gen(function* () { @@ -195,7 +195,7 @@ describe("pg-delta next diagnostic coverage policy", () => { "pg-delta next diagnostic: origin=declarativeLoad code=invalid_routine_body message=routine body failed validation", }); expect(debugMessages).toEqual([]); - }).pipe(Effect.provide(out.layer), Effect.provide(debugLayer(debugMessages))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, debugLayer(debugMessages)))); }); it("classifies all upstream coverage codes and aggregates arbitrary kinds safely", () => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts index 8ff3ae3882..5d655b67fa 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta-next-shadow.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Crypto, Effect, FileSystem, Layer, Option, Path } from "effect"; import * as Net from "node:net"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { @@ -9,6 +9,7 @@ import { } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; @@ -102,6 +103,8 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const viperEnv = yield* LegacyViperEnv; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* LegacyNetworkIdFlag; @@ -116,6 +119,8 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( const runtime = Layer.mergeAll( Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path), + Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(LegacyViperEnv, viperEnv), Layer.succeed(LegacyDebugFlag, debugFlag), Layer.succeed(LegacyExperimentalFlag, experimentalFlag), Layer.succeed(LegacyNetworkIdFlag, networkIdFlag), @@ -133,6 +138,7 @@ export const legacyPgDeltaNextShadowLayer = Layer.effect( const candidate = yield* allocateFreeHostPort; if (Option.isSome(candidate) && candidate.value !== excluded) return candidate.value; } + // oxlint-disable-next-line effecttsgo/unnecessary-fail-yieldable-error -- keep this explicit failure to preserve the shadow layer's generator requirements. return yield* Effect.fail( new LegacyDeclarativeShadowDbError({ message: diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts index 6522c8f877..ffcbf909e8 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.integration.test.ts @@ -1,12 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, FileSystem, Formatter, Layer, Path } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { type LegacyEdgeRuntimeRunOpts, @@ -24,6 +22,10 @@ const CTX: LegacyPgDeltaContext = { denoVersion: 2, projectEnv: {}, }; +const stringifyJson = (value: unknown): string => Formatter.formatJson(value, { space: 2 }); +const legacyTestViperLayer = makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), +); function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: string } = {}) { const calls: Array<LegacyEdgeRuntimeRunOpts> = []; @@ -42,12 +44,14 @@ function fakeEdgeRuntime(outcome: { stdout?: string; stderr?: string; fail?: str return { layer, calls }; } -function makeDeclarativeDir(): string { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-apply-")); - mkdirSync(join(dir, "declarative"), { recursive: true }); - writeFileSync(join(dir, "declarative", "public.sql"), "create table t ();"); - return join(dir, "declarative"); -} +const makeDeclarativeDir = (fs: FileSystem.FileSystem, path: Path.Path) => + Effect.gen(function* () { + const root = yield* fs.makeTempDirectory({ prefix: "legacy-pgdelta-apply-" }); + const dir = path.join(root, "declarative"); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(path.join(dir, "public.sql"), "create table t ();"); + return dir; + }); const failError = (exit: Exit.Exit<unknown, unknown>) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; @@ -79,6 +83,7 @@ describe("legacyApplyDeclarativePgDelta", () => { Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -90,11 +95,12 @@ describe("legacyApplyDeclarativePgDelta", () => { ); it.effect("maps an edge-runtime failure to LegacyPgDeltaDeclarativeApplyError", () => { - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ fail: "error running pg-delta script: boom" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -105,11 +111,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toBe( "error running pg-delta script: boom", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -120,11 +127,12 @@ describe("legacyApplyDeclarativePgDelta", () => { }); it.effect("fails with a parse error WITHOUT the raw stdout when --debug is unset", () => { - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: "not json{" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -136,11 +144,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect(message).toContain("failed to parse pg-delta apply output"); expect(message).not.toContain("stdout:"); expect(message).not.toContain("not json{"); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -151,11 +160,12 @@ describe("legacyApplyDeclarativePgDelta", () => { }); it.effect("fails with a parse error INCLUDING the raw stdout when --debug is set", () => { - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: "not json{" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -166,11 +176,12 @@ describe("legacyApplyDeclarativePgDelta", () => { const message = (failError(exit) as { message: string }).message; expect(message).toContain("failed to parse pg-delta apply output"); expect(message).toContain("stdout: not json{"); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, true), @@ -183,19 +194,14 @@ describe("legacyApplyDeclarativePgDelta", () => { it.effect( "fails with a parse error INCLUDING the raw stdout when SUPABASE_DEBUG is set only in the project .env", () => { - // Go's `Config.Load` -> `loadNestedEnv` `os.Setenv`s the project `supabase/.env` into the - // process before `pgdelta.ApplyDeclarative` ever reads `viper.GetBool("DEBUG")` - // (review: PRRT_kwDOErm0O86XL_oz) — so a `SUPABASE_DEBUG` set only in `supabase/.env`, - // never in the shell or via `--debug`, still surfaces the raw stdout. Delete any shell - // `SUPABASE_DEBUG` first: shell *presence* (even `false`) would otherwise suppress the - // project value entirely, per `legacyViperEnvBoolWithProjectFallback`'s own semantics. - const previous = process.env["SUPABASE_DEBUG"]; - delete process.env["SUPABASE_DEBUG"]; - const dir = makeDeclarativeDir(); + // A `SUPABASE_DEBUG` value supplied through the project environment is resolved by + // `legacyResolveDebugWithProjectEnv`, without mutating the shell environment. const edge = fakeEdgeRuntime({ stdout: "not json{" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta( { ...CTX, projectEnv: { SUPABASE_DEBUG: "true" } }, { @@ -209,17 +215,12 @@ describe("legacyApplyDeclarativePgDelta", () => { const message = (failError(exit) as { message: string }).message; expect(message).toContain("failed to parse pg-delta apply output"); expect(message).toContain("stdout: not json{"); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DEBUG"]; - else process.env["SUPABASE_DEBUG"] = previous; - }), - ), Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -239,11 +240,12 @@ describe("legacyApplyDeclarativePgDelta", () => { // failed-apply summary with every counter at its zero value, rather than treating `null` // as a parse failure. `legacyApplyDeclarativePgDelta` must normalize `null` to `{}` before // its own structural guard, matching that behavior (review: PRRT_kwDOErm0O86W8ZYo). - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: "null" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -259,11 +261,12 @@ describe("legacyApplyDeclarativePgDelta", () => { "failed to parse pg-delta apply output", ); expect(out.stderrText).toContain('pg-delta apply returned status "".'); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -282,11 +285,12 @@ describe("legacyApplyDeclarativePgDelta", () => { // array/string/number/bool payload for a struct destination with an UnmarshalTypeError — // so a bare `JSON.parse(...) as LegacyPgDeltaApplyResult` cast would let `parsed.status` // throw an unhandled TypeError instead of failing typed. - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: "42" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -298,11 +302,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -315,11 +320,12 @@ describe("legacyApplyDeclarativePgDelta", () => { ); it.effect("fails with LegacyPgDeltaDeclarativeApplyError when stdout is a JSON array", () => { - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: "[1,2,3]" }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -331,11 +337,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -352,13 +359,14 @@ describe("legacyApplyDeclarativePgDelta", () => { // not reach `legacyFormatApplyFailure`'s `for (const issue of errors)`, which would throw an // unhandled TypeError on a non-iterable object — Go's `json.Unmarshal` rejects this the same // way, since `Errors` is declared `[]ApplyIssue` (`apps/cli-go/internal/pgdelta/apply.go:33`). - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({ status: "error", errors: { length: 1 } }), }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -370,11 +378,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -394,13 +403,14 @@ describe("legacyApplyDeclarativePgDelta", () => { // (`apps/cli-go/internal/pgdelta/apply.go:124-142`): a numeric element fails BOTH its // string-arm and its object-arm unmarshal, which fails the WHOLE `ApplyResult` decode — // Go never reaches a "success" status in this case, so the TS guard must reject it too. - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({ status: "success", errors: [123] }), }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -412,11 +422,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -434,13 +445,14 @@ describe("legacyApplyDeclarativePgDelta", () => { // Unlike `ApplyIssue`, Go's `ApplyDiagnosis.UnmarshalJSON` (`apply.go:79-116`) has no // bare-string acceptance branch, so `{"diagnostics":["boom"]}` fails Go's whole decode too // (verified: unmarshaling a JSON string into `ApplyDiagnosis`'s shadow struct errors). - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({ status: "success", diagnostics: ["boom"] }), }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -452,11 +464,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -476,7 +489,6 @@ describe("legacyApplyDeclarativePgDelta", () => { // tries `ApplyStatementLocation`, then a bare string, and silently leaves `StatementID` nil // if BOTH fail — never propagating an error. A mistyped `statementId` must NOT fail the // whole parse. - const dir = makeDeclarativeDir(); const payload = { status: "success", diagnostics: [{ message: "note", statementId: 42 }], @@ -485,6 +497,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -492,11 +506,12 @@ describe("legacyApplyDeclarativePgDelta", () => { target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -519,7 +534,6 @@ describe("legacyApplyDeclarativePgDelta", () => { // string) — so Go silently leaves `StatementID` nil rather than erroring the whole parse, // verified empirically. Rendering the raw object anyway would show a bogus `(123#1)` // location Go never emits. - const dir = makeDeclarativeDir(); const payload = { status: "success", diagnostics: [{ message: "note", statementId: { filePath: 123, statementIndex: 1 } }], @@ -528,6 +542,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -535,11 +551,12 @@ describe("legacyApplyDeclarativePgDelta", () => { target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -561,7 +578,6 @@ describe("legacyApplyDeclarativePgDelta", () => { // so `{"errors":[{"message":null}]}` is a valid, Go-accepted payload, not a parse failure. // The formatter's existing `String(issue.message ?? "")` already renders a zero-value // message as "unknown pg-delta issue" once the guard lets the `null` through. - const dir = makeDeclarativeDir(); const payload = { status: "error", totalApplied: 0, @@ -574,6 +590,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -583,11 +601,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); expect(out.stderrText).toContain("- unknown pg-delta issue"); expect(out.stderrText).toContain("- unknown pg-delta diagnostic"); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, true), @@ -609,12 +628,13 @@ describe("legacyApplyDeclarativePgDelta", () => { // `{"status":"success","totalApplied":null}` is a valid, Go-accepted payload, not a parse // failure — same "null means absent" rule already applied to nested issue/diagnostic // scalar fields above. - const dir = makeDeclarativeDir(); const payload = { status: "success", totalApplied: null, totalRounds: null }; const edge = fakeEdgeRuntime({ stdout: JSON.stringify(payload) }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -623,11 +643,12 @@ describe("legacyApplyDeclarativePgDelta", () => { }).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Applied 0 statements in 0 round(s)."); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -646,11 +667,12 @@ describe("legacyApplyDeclarativePgDelta", () => { // non-pointer `string` field decoded via the default `encoding/json` — verified // empirically that `{}` and `{"status":null}` both decode with `err == nil` and // `Status == ""`, reaching the normal failed-apply summary (not a parse failure). - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({}) }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -663,11 +685,12 @@ describe("legacyApplyDeclarativePgDelta", () => { "pg-delta declarative apply failed with status: ", ); expect(out.stderrText).toContain('pg-delta apply returned status "".'); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -688,7 +711,6 @@ describe("legacyApplyDeclarativePgDelta", () => { // `json.Unmarshal([]byte(\`{"status":"error","errors":null}\`), &r)` returns `err == nil` // with `len(r.Errors) == 0`. A payload reporting all four as `null` must format as if none // were reported at all, not fail the parse. - const dir = makeDeclarativeDir(); const payload = { status: "error", totalApplied: 0, @@ -703,6 +725,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -711,11 +735,12 @@ describe("legacyApplyDeclarativePgDelta", () => { }).pipe(Effect.exit); expect(failError(exit)?.constructor.name).toBe("LegacyPgDeltaDeclarativeApplyError"); expect(out.stderrText).toContain("No per-statement diagnostics were reported by pg-delta."); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -733,13 +758,14 @@ describe("legacyApplyDeclarativePgDelta", () => { // Same reasoning as the array-typed-field test above, for `ApplyResult`'s numeric fields // (`TotalApplied int`, etc.) — a malformed counter must fail the parse, not be silently // treated as a genuine successful-apply summary. - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({ status: "success", totalApplied: "5" }), }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -751,11 +777,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -775,13 +802,14 @@ describe("legacyApplyDeclarativePgDelta", () => { // empirically that `json.Unmarshal` on `{"totalApplied":1.5}` errors identically to a // string-typed field mismatch, so `1.5` must fail the parse here too, not be treated as a // truncated/rounded successful-apply count. - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({ status: "success", totalApplied: 1.5 }), }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -793,11 +821,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -816,13 +845,14 @@ describe("legacyApplyDeclarativePgDelta", () => { // into `int` fails with "value out of range" (`strconv.ParseInt`'s int64 width) — so a // mistyped/oversized numeric field must be rejected here too, not accepted as a (false) // successful-apply count. - const dir = makeDeclarativeDir(); const edge = fakeEdgeRuntime({ stdout: JSON.stringify({ status: "success", totalApplied: 1e20 }), }); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -834,11 +864,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect((failError(exit) as { message: string }).message).toContain( "failed to parse pg-delta apply output", ); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -852,7 +883,6 @@ describe("legacyApplyDeclarativePgDelta", () => { it.effect( "on a non-success status, prints the formatted failure to stderr but not the raw payload when --debug is unset", () => { - const dir = makeDeclarativeDir(); const payload = { status: "error", totalApplied: 0, @@ -864,6 +894,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); const exit = yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -877,11 +909,12 @@ describe("legacyApplyDeclarativePgDelta", () => { expect(out.stderrText).toContain('pg-delta apply returned status "error".'); expect(out.stderrText).toContain("- boom"); expect(out.stderrText).not.toContain("pg-delta apply result:"); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), @@ -896,7 +929,6 @@ describe("legacyApplyDeclarativePgDelta", () => { it.effect( "on a non-success status with --debug set, additionally dumps the pretty-printed raw payload", () => { - const dir = makeDeclarativeDir(); const payload = { status: "error", totalApplied: 0, @@ -908,6 +940,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -915,12 +949,13 @@ describe("legacyApplyDeclarativePgDelta", () => { target: "postgresql://postgres:postgres@127.0.0.1:54320/contrib_regression", }).pipe(Effect.exit); expect(out.stderrText).toContain("pg-delta apply result:"); - expect(out.stderrText).toContain(JSON.stringify(payload, null, 2)); - rmSync(dir, { recursive: true, force: true }); + expect(out.stderrText).toContain(stringifyJson(payload)); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, true), @@ -935,7 +970,6 @@ describe("legacyApplyDeclarativePgDelta", () => { it.effect( "on success, prints the applied-statements summary and forwards SCHEMA_PATH/TARGET/binds", () => { - const dir = makeDeclarativeDir(); const payload = { status: "success", totalStatements: 3, @@ -947,6 +981,8 @@ describe("legacyApplyDeclarativePgDelta", () => { const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* makeDeclarativeDir(fs, path); yield* legacyApplyDeclarativePgDelta(CTX, { fs, declarativeDirAbs: dir, @@ -965,11 +1001,12 @@ describe("legacyApplyDeclarativePgDelta", () => { `${dir}:/declarative:ro`, ]); expect(opts.errPrefix).toBe("error running pg-delta script"); - rmSync(dir, { recursive: true, force: true }); + yield* fs.remove(dir, { recursive: true }); }).pipe( Effect.provide( Layer.mergeAll( BunServices.layer, + legacyTestViperLayer, edge.layer, out.layer, Layer.succeed(LegacyDebugFlag, false), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts index da63c39645..61846eac61 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.apply.ts @@ -10,7 +10,7 @@ * Go binary until now. */ -import { Data, Effect, type FileSystem } from "effect"; +import { Data, Effect, Schema, type FileSystem } from "effect"; import { legacyResolveDebugWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -898,12 +898,10 @@ export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( .exists(params.declarativeDirAbs) .pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return yield* Effect.fail( - new LegacyPgDeltaDeclarativeApplyError({ - message: `declarative schema directory not found: ${params.declarativeDirRel}`, - reason: "missing_schema_dir", - }), - ); + return yield* new LegacyPgDeltaDeclarativeApplyError({ + message: `declarative schema directory not found: ${params.declarativeDirRel}`, + reason: "missing_schema_dir", + }); } const output = yield* Output; @@ -931,7 +929,7 @@ export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( `${legacyEdgeRuntimeId(ctx.projectId)}:/root/.cache/deno:rw`, `${params.declarativeDirAbs}:${LEGACY_PG_DELTA_APPLY_CONTAINER_SCHEMA_PATH}:ro`, ]; - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); + const npm = yield* legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeApplyScript, ctx.npmVersion), @@ -940,6 +938,7 @@ export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( errPrefix: "error running pg-delta script", extraFiles: npm.extraFiles, extraEnv: npm.extraEnv, + projectEnvValues: ctx.projectEnv, denoVersion: ctx.denoVersion, workdir: ctx.cwd, }) @@ -950,28 +949,34 @@ export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( ), ); - const parsed = yield* Effect.try({ - try: () => { - const raw: unknown = JSON.parse(result.stdout); - // Go's `json.Unmarshal` accepts a top-level JSON `null` for the non-pointer - // `ApplyResult` destination and leaves it zero-valued, with no error (verified - // empirically) — so a `null` payload must fall through to the normal - // `status !== "success"` failure path below, not be misclassified as a parse - // failure. See {@link legacyIsPgDeltaApplyResult}'s own doc comment. - const normalized: unknown = raw === null ? {} : raw; - if (!legacyIsPgDeltaApplyResult(normalized)) { - throw new Error("pg-delta apply output was not a JSON object"); - } - return normalized; - }, - catch: (cause) => - new LegacyPgDeltaDeclarativeApplyError({ - message: debug - ? `failed to parse pg-delta apply output: ${errMessage(cause)}\nstdout: ${result.stdout}` - : `failed to parse pg-delta apply output: ${errMessage(cause)}`, - reason: "output_parse", - }), - }); + const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + result.stdout, + ).pipe( + Effect.mapError( + (cause) => + new LegacyPgDeltaDeclarativeApplyError({ + message: debug + ? `failed to parse pg-delta apply output: ${errMessage(cause)}\nstdout: ${result.stdout}` + : `failed to parse pg-delta apply output: ${errMessage(cause)}`, + reason: "output_parse", + }), + ), + ); + // Go's `json.Unmarshal` accepts a top-level JSON `null` for the non-pointer + // `ApplyResult` destination and leaves it zero-valued, with no error (verified + // empirically) — so a `null` payload must fall through to the normal + // `status !== "success"` failure path below, not be misclassified as a parse + // failure. See {@link legacyIsPgDeltaApplyResult}'s own doc comment. + const normalized: unknown = decoded === null ? {} : decoded; + if (!legacyIsPgDeltaApplyResult(normalized)) { + return yield* new LegacyPgDeltaDeclarativeApplyError({ + message: debug + ? `failed to parse pg-delta apply output: pg-delta apply output was not a JSON object\nstdout: ${result.stdout}` + : "failed to parse pg-delta apply output: pg-delta apply output was not a JSON object", + reason: "output_parse", + }); + } + const parsed = normalized; if (parsed.status !== "success") { // `output.rawBytes`, not `output.raw`: `legacyFormatApplyFailure` returns a `Buffer` that @@ -989,11 +994,9 @@ export const legacyApplyDeclarativePgDelta = Effect.fnUntraced(function* ( yield* output.raw(`${debugJson}\n`, "stderr"); } } - return yield* Effect.fail( - new LegacyPgDeltaDeclarativeApplyError({ - message: `pg-delta declarative apply failed with status: ${parsed.status ?? ""}`, - }), - ); + return yield* new LegacyPgDeltaDeclarativeApplyError({ + message: `pg-delta declarative apply failed with status: ${parsed.status ?? ""}`, + }); } yield* output.raw( `Applied ${parsed.totalApplied ?? 0} statements in ${parsed.totalRounds ?? 0} round(s).\n`, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts index c26287ee4b..6f24950352 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.deno-templates.unit.test.ts @@ -1,6 +1,7 @@ -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { LEGACY_DEFAULT_PG_DELTA_NPM_VERSION, @@ -21,19 +22,34 @@ const goDiffTemplatesDir = fileURLToPath( const goPgDeltaTemplatesDir = fileURLToPath( new URL("../../../../../../cli-go/internal/pgdelta/templates/", import.meta.url), ); -const readGoTemplate = (name: string) => readFileSync(`${goDiffTemplatesDir}${name}`, "utf8"); +const readGoTemplate = (name: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(goDiffTemplatesDir, name)); + }); +const readPgDeltaTemplate = (name: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(goPgDeltaTemplatesDir, name)); + }); describe("embedded pg-delta Deno templates", () => { - it("match the Go sources byte-for-byte", () => { - expect(legacyPgDeltaDiffScript).toBe(readGoTemplate("pgdelta.ts")); - expect(legacyPgDeltaDeclarativeExportScript).toBe( - readGoTemplate("pgdelta_declarative_export.ts"), - ); - expect(legacyPgDeltaCatalogExportScript).toBe(readGoTemplate("pgdelta_catalog_export.ts")); - expect(legacyPgDeltaDeclarativeApplyScript).toBe( - readFileSync(`${goPgDeltaTemplatesDir}pgdelta_declarative_apply.ts`, "utf8"), - ); - }); + it.effect("match the Go sources byte-for-byte", () => + Effect.gen(function* () { + expect(legacyPgDeltaDiffScript).toBe(yield* readGoTemplate("pgdelta.ts")); + expect(legacyPgDeltaDeclarativeExportScript).toBe( + yield* readGoTemplate("pgdelta_declarative_export.ts"), + ); + expect(legacyPgDeltaCatalogExportScript).toBe( + yield* readGoTemplate("pgdelta_catalog_export.ts"), + ); + expect(legacyPgDeltaDeclarativeApplyScript).toBe( + yield* readPgDeltaTemplate("pgdelta_declarative_apply.ts"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); it("pin the placeholder npm version that interpolation rewrites", () => { expect(legacyPgDeltaDiffScript).toContain( diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index ec0927af3c..0fdaaaafd9 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -1,9 +1,6 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, FileSystem, Exit, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -33,6 +30,11 @@ import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe. import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; import { legacyDeclarativeSeamLayer } from "./legacy-pgdelta.seam.layer.ts"; import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; + +const legacyViperEnvLayer = makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), +); /** * Integration coverage for the fully-native `legacyDeclarativeSeamLayer` (CLI-1970) — @@ -152,10 +154,12 @@ function setup( // satisfy as it's applied, so `BunServices.layer` only ever fills in `FileSystem`/`Path`. Layer.provide(shadowSpawner.layer), Layer.provide(BunServices.layer), + Layer.provide(legacyViperEnvLayer), ); const layer = Layer.mergeAll( BunServices.layer, + legacyViperEnvLayer, out.layer, shadowSpawner.layer, dbConnection.layer, @@ -178,69 +182,86 @@ function setup( const failError = (exit: Exit.Exit<unknown, unknown>) => Exit.isFailure(exit) ? exit.cause.reasons.find(Cause.isFailReason)?.error : undefined; +const withTempWorkdir = <A, E, R>( + run: (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) => Effect.Effect<A, E, R>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-pgdelta-seam-" }); + return yield* run(fs, path, workdir); + }); + describe("legacyDeclarativeSeamLayer.exportCatalog", () => { it.effect( "provisions a shadow on a baseline cache miss, then reuses the cached catalog with no further container work", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer, out, shadowSpawned } = setup(dir); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; + return withTempWorkdir((fs, path, dir) => { + const { layer, out, shadowSpawned } = setup(dir); + return Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; - const firstRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); - expect(firstRef).toMatch(/^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/); - expect(readFileSync(join(dir, firstRef), "utf8")).toBe('{"schemas":[]}'); - expect(out.stderrText).toContain("Creating shadow database...\n"); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + const firstRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); + expect(firstRef).toMatch( + /^supabase[/\\]\.temp[/\\]pgdelta[/\\]catalog-baseline-.*\.json$/, + ); + expect(yield* fs.readFileString(path.join(dir, firstRef))).toBe('{"schemas":[]}'); + expect(out.stderrText).toContain("Creating shadow database...\n"); + expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // Cache hit: same ref, zero additional container work. - const secondRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); - expect(secondRef).toBe(firstRef); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); + // Cache hit: same ref, zero additional container work. + const secondRef = yield* seam.exportCatalog({ mode: "baseline", noCache: false }); + expect(secondRef).toBe(firstRef); + expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect( "writes catalog-nocache-declarative.json on --no-cache, applying the declarative directory first", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const declDir = join(dir, "supabase", "schemas"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "create table t ();"); - const { layer, edgeCalls, shadowSpawned } = setup(dir); + return withTempWorkdir((fs, path, dir) => { + const { layer, edgeCalls, shadowSpawned } = setup(dir); + return Effect.gen(function* () { + yield* fs.makeDirectory(path.join(dir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "supabase", "schemas", "public.sql"), + "create table t ();", + ); + const seam = yield* LegacyDeclarativeSeam; + const ref = yield* seam.exportCatalog({ mode: "declarative", noCache: true }); + expect(ref).toBe( + path.join("supabase", ".temp", "pgdelta", "catalog-nocache-declarative.json"), + ); + expect(yield* fs.readFileString(path.join(dir, ref))).toBe('{"schemas":[]}'); + expect(edgeCalls.some((c) => c.errPrefix === "error running pg-delta script")).toBe(true); + expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); + expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); + }, + ); + + it.effect("maps a shadow-provisioning failure to LegacyDeclarativeShadowDbError", () => + withTempWorkdir((_fs, _path, dir) => { + const { layer } = setup(dir, { failCreate: true }); return Effect.gen(function* () { const seam = yield* LegacyDeclarativeSeam; - const ref = yield* seam.exportCatalog({ mode: "declarative", noCache: true }); - expect(ref).toBe(join("supabase", ".temp", "pgdelta", "catalog-nocache-declarative.json")); - expect(readFileSync(join(dir, ref), "utf8")).toBe('{"schemas":[]}'); - expect(edgeCalls.some((c) => c.errPrefix === "error running pg-delta script")).toBe(true); - expect(shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - expect(shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - rmSync(dir, { recursive: true, force: true }); + const exit = yield* seam + .exportCatalog({ mode: "baseline", noCache: true }) + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = failError(exit); + expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); + expect((error as LegacyDeclarativeShadowDbError).message).toContain( + "failed to provision the shadow database:", + ); }).pipe(Effect.provide(layer)); - }, + }).pipe(Effect.provide(BunServices.layer)), ); - - it.effect("maps a shadow-provisioning failure to LegacyDeclarativeShadowDbError", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer } = setup(dir, { failCreate: true }); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const exit = yield* seam.exportCatalog({ mode: "baseline", noCache: true }).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); - expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); - expect((error as LegacyDeclarativeShadowDbError).message).toContain( - "failed to provision the shadow database:", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); - }); }); describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { @@ -253,22 +274,22 @@ describe("legacyDeclarativeSeamLayer.ensureLocalDatabaseStarted", () => { // debug hint instead of the actionable Docker recovery text (review: the start-failure // catch below it already propagates `suggestion`; this asserts the inspect mapping does // too). - const dir = mkdtempSync(join(tmpdir(), "legacy-pgdelta-seam-")); - const { layer } = setup(dir, { - dbInspectFailsWith: - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", - }); - return Effect.gen(function* () { - const seam = yield* LegacyDeclarativeSeam; - const exit = yield* seam.ensureLocalDatabaseStarted().pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - const error = failError(exit); - expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); - const shadowError = error as LegacyDeclarativeShadowDbError; - expect(shadowError.docker).toBe("daemon"); - expect(shadowError.suggestion).toBe(LEGACY_SUGGEST_DOCKER_INSTALL); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); + return withTempWorkdir((_fs, _path, dir) => { + const { layer } = setup(dir, { + dbInspectFailsWith: + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + }); + return Effect.gen(function* () { + const seam = yield* LegacyDeclarativeSeam; + const exit = yield* seam.ensureLocalDatabaseStarted.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + const error = failError(exit); + expect(error).toBeInstanceOf(LegacyDeclarativeShadowDbError); + const shadowError = error as LegacyDeclarativeShadowDbError; + expect(shadowError.docker).toBe("daemon"); + expect(shadowError.suggestion).toBe(LEGACY_SUGGEST_DOCKER_INSTALL); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts index 2b9d762090..a13f4f4e1f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.layer.ts @@ -19,6 +19,7 @@ import { } from "../../../shared/legacy-docker-ids.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; import { LegacyDeclarativeSeam } from "./legacy-pgdelta.seam.service.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; const legacyShadowDockerCause = ( stderr: string, @@ -83,6 +84,7 @@ export const legacyDeclarativeSeamLayer = Layer.effect( const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const legacyEnv = yield* LegacyViperEnv; // Captures every OTHER service `legacyExportBaselineCatalogRef`/ // `legacyExportDeclarativeCatalogRef`/`legacyStartLocalDatabase` need internally (Output, // RuntimeInfo, HttpClient, LegacyDbConnection, LegacyEdgeRuntimeScript, LegacyDockerRun, @@ -93,8 +95,10 @@ export const legacyDeclarativeSeamLayer = Layer.effect( // `legacy-platform-api-factory.layer.ts`'s identical capture-and-provide shape. const context = yield* Effect.context< | LegacyExportBaselineCatalogDeps - | LegacyExportDeclarativeCatalogDeps | LegacyStartLocalDatabaseDeps + | LegacyViperEnv + | FileSystem.FileSystem + | Path.Path >(); return LegacyDeclarativeSeam.of({ @@ -108,163 +112,160 @@ export const legacyDeclarativeSeamLayer = Layer.effect( noCache, projectRef, }) + ).pipe(Effect.provideContext(context), Effect.mapError(legacyToShadowDbError)), + ensureLocalDatabaseStarted: Effect.gen(function* () { + const running = yield* legacyIsLocalDbRunning( + spawner, + fs, + path, + cliConfig.workdir, + Option.getOrUndefined(cliConfig.projectId), ).pipe( Effect.provideContext(context), - Effect.catch((cause) => Effect.fail(legacyToShadowDbError(cause))), - ), - ensureLocalDatabaseStarted: () => + Effect.mapError( + (cause) => + new LegacyDeclarativeShadowDbError({ + message: cause.message, + ...(cause.daemonDown === true ? { docker: "daemon" as const } : {}), + // Same propagation as the start-failure catch below: the inspect error's + // Docker-install recovery text (Go's `utils.CmdSuggestion`) must survive the + // seam, or the normalizer falls back to its generic debug hint. + ...(cause.suggestion !== undefined ? { suggestion: cause.suggestion } : {}), + }), + ), + ); + if (running) return; // already running — the seam never prints anything here. + yield* legacyStartLocalDatabase().pipe( + Effect.provideContext(context), + Effect.asVoid, + Effect.mapError( + (cause) => + new LegacyDeclarativeShadowDbError({ + message: `failed to start local database: ${cause.message}`, + ...(legacyHasDaemonSignal(cause) ? { docker: "daemon" as const } : {}), + ...("suggestion" in cause && typeof cause.suggestion === "string" + ? { suggestion: cause.suggestion } + : {}), + }), + ), + ); + }), + ensureLocalPostgresImageCurrent: Effect.scoped( Effect.gen(function* () { - const running = yield* legacyIsLocalDbRunning( - spawner, + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( + Effect.provideContext(context), + Effect.mapError( + (error) => + new LegacyDeclarativeShadowDbError({ + message: `failed to read config for local Postgres image check: ${error.message}`, + }), + ), + ); + const image = yield* legacyResolveDbImage( fs, path, cliConfig.workdir, + toml.majorVersion, + Option.getOrUndefined(toml.orioledbVersion), + ).pipe(Effect.provideContext(context)); + const tomlProjectId = toml.projectId; + const projectId = legacyResolveLocalProjectId( Option.getOrUndefined(cliConfig.projectId), + Option.getOrUndefined(tomlProjectId), + cliConfig.workdir, + ); + const containerId = localDbContainerId(projectId); + const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + extendEnv: true, + }).pipe( + Effect.mapError( + () => + new LegacyDeclarativeShadowDbError({ + message: "failed to inspect local Postgres container.", + docker: "daemon", + }), + ), + ); + const stdoutChunks: Array<Uint8Array> = []; + const stderrChunks: Array<Uint8Array> = []; + yield* Stream.runForEach(child.stdout, (chunk) => + Effect.sync(() => { + stdoutChunks.push(chunk); + }), ).pipe( Effect.mapError( - (cause) => + () => new LegacyDeclarativeShadowDbError({ - message: cause.message, - ...(cause.daemonDown === true ? { docker: "daemon" as const } : {}), - // Same propagation as the start-failure catch below: the inspect error's - // Docker-install recovery text (Go's `utils.CmdSuggestion`) must survive the - // seam, or the normalizer falls back to its generic debug hint. - ...(cause.suggestion !== undefined ? { suggestion: cause.suggestion } : {}), + message: "failed to inspect local Postgres container.", + docker: "daemon", }), ), ); - if (running) return; // already running — the seam never prints anything here. - yield* legacyStartLocalDatabase().pipe( - Effect.provideContext(context), - Effect.asVoid, - Effect.catch((cause) => - Effect.fail( + yield* Stream.runForEach(child.stderr, (chunk) => + Effect.sync(() => { + stderrChunks.push(chunk); + }), + ).pipe( + Effect.mapError( + () => new LegacyDeclarativeShadowDbError({ - message: `failed to start local database: ${cause.message}`, - ...(legacyHasDaemonSignal(cause) ? { docker: "daemon" as const } : {}), - ...("suggestion" in cause && typeof cause.suggestion === "string" - ? { suggestion: cause.suggestion } - : {}), + message: "failed to inspect local Postgres container.", + docker: "daemon", }), - ), ), ); - }), - ensureLocalPostgresImageCurrent: () => - Effect.scoped( - Effect.gen(function* () { - const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir).pipe( - Effect.mapError( - (error) => - new LegacyDeclarativeShadowDbError({ - message: `failed to read config for local Postgres image check: ${error.message}`, - }), - ), - ); - const image = yield* legacyResolveDbImage( - fs, - path, - cliConfig.workdir, - toml.majorVersion, - Option.getOrUndefined(toml.orioledbVersion), - ); - const tomlProjectId = toml.projectId; - const projectId = legacyResolveLocalProjectId( - Option.getOrUndefined(cliConfig.projectId), - Option.getOrUndefined(tomlProjectId), - cliConfig.workdir, - ); - const containerId = localDbContainerId(projectId); - const child = yield* spawnContainerCli(spawner, ["container", "inspect", containerId], { - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - extendEnv: true, - }).pipe( - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - const stdoutChunks: Array<Uint8Array> = []; - const stderrChunks: Array<Uint8Array> = []; - yield* Stream.runForEach(child.stdout, (chunk) => - Effect.sync(() => { - stdoutChunks.push(chunk); - }), - ).pipe( - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - yield* Stream.runForEach(child.stderr, (chunk) => - Effect.sync(() => { - stderrChunks.push(chunk); - }), - ).pipe( - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - const inspectExit = yield* child.exitCode.pipe( - Effect.map(Number), - Effect.mapError( - () => - new LegacyDeclarativeShadowDbError({ - message: "failed to inspect local Postgres container.", - docker: "daemon", - }), - ), - ); - const decodeChunks = (chunks: ReadonlyArray<Uint8Array>): string => { - const total = chunks.reduce((size, chunk) => size + chunk.length, 0); - const bytes = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - return new TextDecoder().decode(bytes).trim(); - }; - const stderr = decodeChunks(stderrChunks); - const stdout = decodeChunks(stdoutChunks); - if (inspectExit !== 0) { - if (legacyIsMissingContainerInspectError(stderr)) return; - return yield* Effect.fail( + const inspectExit = yield* child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + () => new LegacyDeclarativeShadowDbError({ - message: - stderr.length > 0 - ? `failed to inspect local Postgres container: ${stderr}` - : "failed to inspect local Postgres container.", - ...legacyShadowDockerCause(stderr), + message: "failed to inspect local Postgres container.", + docker: "daemon", }), - ); - } - const actual = legacyResolveContainerInspectImageName(stdout); - const expected = legacyGetRegistryImageUrl(image).trim(); - const actualTag = dockerImageTag(actual); - const expectedTag = dockerImageTag(expected); - if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { - return; + ), + ); + const decodeChunks = (chunks: ReadonlyArray<Uint8Array>): string => { + const total = chunks.reduce((size, chunk) => size + chunk.length, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; } - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ - message: `local Postgres container image is stale: running ${actual} but expected ${expected}. Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas.`, - }), - ); - }), - ), + return new TextDecoder().decode(bytes).trim(); + }; + const stderr = decodeChunks(stderrChunks); + const stdout = decodeChunks(stdoutChunks); + if (inspectExit !== 0) { + if (legacyIsMissingContainerInspectError(stderr)) return; + return yield* new LegacyDeclarativeShadowDbError({ + message: + stderr.length > 0 + ? `failed to inspect local Postgres container: ${stderr}` + : "failed to inspect local Postgres container.", + ...legacyShadowDockerCause(stderr), + }); + } + const actual = legacyResolveContainerInspectImageName(stdout); + const registryOverride = yield* legacyEnv + .get("SUPABASE_INTERNAL_IMAGE_REGISTRY") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); + const expected = legacyGetRegistryImageUrl(image, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: Option.getOrElse(registryOverride, () => ""), + }).trim(); + const actualTag = dockerImageTag(actual); + const expectedTag = dockerImageTag(expected); + if (actualTag.length === 0 || expectedTag.length === 0 || actualTag === expectedTag) { + return; + } + return yield* new LegacyDeclarativeShadowDbError({ + message: `local Postgres container image is stale: running ${actual} but expected ${expected}. Run supabase stop --all --no-backup, then supabase start before syncing declarative schemas.`, + }); + }), + ), }); }), ); @@ -278,15 +279,6 @@ type LegacyExportBaselineCatalogDeps = ? R : never; -type LegacyExportDeclarativeCatalogDeps = - ReturnType<typeof legacyExportDeclarativeCatalogRef> extends Effect.Effect< - infer _A, - infer _E, - infer R - > - ? R - : never; - type LegacyStartLocalDatabaseDeps = ReturnType<typeof legacyStartLocalDatabase> extends Effect.Effect<infer _A, infer _E, infer R> ? R diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts index 87afbaf407..71bbd8889f 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.service.ts @@ -35,16 +35,13 @@ interface LegacyDeclarativeSeamShape { * `db schema declarative generate --local`/`sync` can bootstrap a stopped stack instead of * failing to connect. A no-op, silently, when the container is already running. */ - readonly ensureLocalDatabaseStarted: () => Effect.Effect<void, LegacyDeclarativeShadowDbError>; + readonly ensureLocalDatabaseStarted: Effect.Effect<void, LegacyDeclarativeShadowDbError>; /** * Checks the running local Postgres container image tag against the currently * resolved Postgres image. A missing container is accepted: catalog cache keys * self-invalidate on setup inputs, and local-apply paths will start/connect later. */ - readonly ensureLocalPostgresImageCurrent: () => Effect.Effect< - void, - LegacyDeclarativeShadowDbError - >; + readonly ensureLocalPostgresImageCurrent: Effect.Effect<void, LegacyDeclarativeShadowDbError>; } export class LegacyDeclarativeSeam extends Context.Service< diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts index 62912a0cd5..8d969a614a 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.ts @@ -1,4 +1,4 @@ -import { Effect, type FileSystem, type Path } from "effect"; +import { Effect, Schema, type FileSystem, type Path } from "effect"; import { classifySqlFiles } from "@supabase/pg-delta/frontends"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -223,7 +223,9 @@ const writeNextDeclarativeSchemas = Effect.fnUntraced(function* ( ...output.manifest, files: proposed.map((file) => file.name).sort(), }; - const serialized = `${JSON.stringify(manifest, null, 2)}\n`; + const serialized = `${yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown, { space: 2 }), + )(manifest)}\n`; const manifestPath = path.join(declarativeDir, EXPORT_MANIFEST_FILE); const manifestExists = yield* fs .exists(manifestPath) diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts index 184642e0f2..5c804bf7d0 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.write.unit.test.ts @@ -1,11 +1,7 @@ -import { existsSync, mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; +import { DateTime, Effect, FileSystem, Option, Path, Schema } from "effect"; -import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import type { LegacyDeclarativeOutput } from "../../../shared/legacy-pgdelta.ts"; import { LegacyDeclarativeWriteError } from "./legacy-pgdelta.errors.ts"; @@ -23,7 +19,7 @@ const write = ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* legacyWriteDeclarativeSchemas(fs, path, declarativeDir, output); - }).pipe(Effect.provide(BunServices.layer)); + }); const nextOutput = (files: LegacyPgDeltaDeclarativeExportResult["files"]) => ({ files, @@ -31,36 +27,38 @@ const nextOutput = (files: LegacyPgDeltaDeclarativeExportResult["files"]) => ({ }); describe("legacyWriteDeclarativeSchemas", () => { - const tmp = useLegacyTempWorkdir("legacy-decl-write-"); - const declarativeDir = () => join(tmp.current, "supabase", "database"); - - it.effect("keeps the legacy wipe-and-rewrite behavior", () => { - const dir = declarativeDir(); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, "stale.sql"), "-- should be removed"); - return write(dir, { - version: 1, - mode: "declarative", - files: [ - { path: "public.sql", order: 0, statements: 1, sql: "create table a();" }, - { path: "auth/roles.sql", order: 1, statements: 1, sql: "create role app;" }, - ], - }).pipe( - Effect.tap((written) => - Effect.sync(() => { - expect(written.preservedUnmanagedFiles).toEqual([]); - expect(existsSync(join(dir, "stale.sql"))).toBe(false); - expect(readFileSync(join(dir, "public.sql"), "utf8")).toBe("create table a();"); - expect(readFileSync(join(dir, "auth", "roles.sql"), "utf8")).toBe("create role app;"); - expect(existsSync(join(dir, ".pgdelta-export.json"))).toBe(false); - }), - ), - ); - }); + it.effect("keeps the legacy wipe-and-rewrite behavior", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-decl-write-" }); + const dir = path.join(root, "supabase", "database"); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(path.join(dir, "stale.sql"), "-- should be removed"); + const written = yield* write(dir, { + version: 1, + mode: "declarative", + files: [ + { path: "public.sql", order: 0, statements: 1, sql: "create table a();" }, + { path: "auth/roles.sql", order: 1, statements: 1, sql: "create role app;" }, + ], + }); + expect(written.preservedUnmanagedFiles).toEqual([]); + expect(yield* fs.exists(path.join(dir, "stale.sql"))).toBe(false); + expect(yield* fs.readFileString(path.join(dir, "public.sql"))).toBe("create table a();"); + expect(yield* fs.readFileString(path.join(dir, "auth", "roles.sql"))).toBe( + "create role app;", + ); + expect(yield* fs.exists(path.join(dir, ".pgdelta-export.json"))).toBe(false); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.effect("tracks next-engine ownership while preserving custom and unmanaged files", () => { - const dir = declarativeDir(); - return Effect.gen(function* () { + it.effect("tracks next-engine ownership while preserving custom and unmanaged files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-decl-write-" }); + const dir = path.join(root, "supabase", "database"); yield* write( dir, nextOutput([ @@ -68,9 +66,12 @@ describe("legacyWriteDeclarativeSchemas", () => { { name: "stale.sql", sql: "select 'remove later';" }, ]), ); - mkdirSync(join(dir, "_custom"), { recursive: true }); - writeFileSync(join(dir, "_custom", "casts.sql"), "create cast (int as text);"); - writeFileSync(join(dir, "unmanaged.sql"), "select 'keep me';"); + yield* fs.makeDirectory(path.join(dir, "_custom"), { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "_custom", "casts.sql"), + "create cast (int as text);", + ); + yield* fs.writeFileString(path.join(dir, "unmanaged.sql"), "select 'keep me';"); const written = yield* write( dir, @@ -85,86 +86,95 @@ describe("legacyWriteDeclarativeSchemas", () => { ); expect(written.preservedUnmanagedFiles).toEqual([]); - expect(existsSync(join(dir, "stale.sql"))).toBe(false); - expect(readFileSync(join(dir, "_cluster", "roles.sql"), "utf8")).toBe("create role app;"); - expect(readFileSync(join(dir, "unmanaged.sql"), "utf8")).toBe("select 'keep me';"); - expect(readFileSync(join(dir, "_custom", "casts.sql"), "utf8")).toBe( + expect(yield* fs.exists(path.join(dir, "stale.sql"))).toBe(false); + expect(yield* fs.readFileString(path.join(dir, "_cluster", "roles.sql"))).toBe( + "create role app;", + ); + expect(yield* fs.readFileString(path.join(dir, "unmanaged.sql"))).toBe("select 'keep me';"); + expect(yield* fs.readFileString(path.join(dir, "_custom", "casts.sql"))).toBe( "create cast (int as text);", ); - expect(JSON.parse(readFileSync(join(dir, ".pgdelta-export.json"), "utf8"))).toEqual({ + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(path.join(dir, ".pgdelta-export.json")), + ), + ).toEqual({ formatVersion: 1, redactSecrets: true, scope: "database", profile: "supabase", files: ["_cluster/roles.sql", "app/tables/a.sql", "app/tables/z.sql"], }); - }); - }); - - it.effect("reports manifestless files that the next writer preserves", () => { - const dir = declarativeDir(); - mkdirSync(join(dir, "_custom"), { recursive: true }); - writeFileSync(join(dir, "_custom", "casts.sql"), "select 'custom';"); - writeFileSync(join(dir, "legacy-b.sql"), "select 'b';"); - writeFileSync(join(dir, "legacy-a.sql"), "select 'a';"); - writeFileSync(join(dir, "replaced.sql"), "-- old"); - - return write( - dir, - nextOutput([{ name: "replaced.sql", sql: "create table public.example(id int);" }]), - ).pipe( - Effect.tap((written) => - Effect.sync(() => { - expect(written.preservedUnmanagedFiles).toEqual(["legacy-a.sql", "legacy-b.sql"]); - expect(readFileSync(join(dir, "replaced.sql"), "utf8")).toContain("create table"); - }), - ), - ); - }); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.effect("does not rewrite unchanged next-engine files or manifests", () => { - const dir = declarativeDir(); - const schemaPath = join(dir, "public", "schema.sql"); - const manifestPath = join(dir, ".pgdelta-export.json"); - const output = nextOutput([ - { name: "public/schema.sql", sql: "create table public.example(id int);" }, - ]); + it.effect("reports manifestless files that the next writer preserves", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-decl-write-" }); + const dir = path.join(root, "supabase", "database"); + yield* fs.makeDirectory(path.join(dir, "_custom"), { recursive: true }); + yield* fs.writeFileString(path.join(dir, "_custom", "casts.sql"), "select 'custom';"); + yield* fs.writeFileString(path.join(dir, "legacy-b.sql"), "select 'b';"); + yield* fs.writeFileString(path.join(dir, "legacy-a.sql"), "select 'a';"); + yield* fs.writeFileString(path.join(dir, "replaced.sql"), "-- old"); + const written = yield* write( + dir, + nextOutput([{ name: "replaced.sql", sql: "create table public.example(id int);" }]), + ); + expect(written.preservedUnmanagedFiles).toEqual(["legacy-a.sql", "legacy-b.sql"]); + expect(yield* fs.readFileString(path.join(dir, "replaced.sql"))).toContain("create table"); + }).pipe(Effect.provide(BunServices.layer)), + ); - return write(dir, output).pipe( - Effect.tap(() => - Effect.sync(() => { - const old = new Date("2020-01-01T00:00:00.000Z"); - utimesSync(schemaPath, old, old); - utimesSync(manifestPath, old, old); - }), - ), - Effect.andThen(write(dir, output)), - Effect.tap(() => - Effect.sync(() => { - expect(statSync(schemaPath).mtime.toISOString()).toBe("2020-01-01T00:00:00.000Z"); - expect(statSync(manifestPath).mtime.toISOString()).toBe("2020-01-01T00:00:00.000Z"); - }), - ), - ); - }); + it.effect("does not rewrite unchanged next-engine files or manifests", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-decl-write-" }); + const dir = path.join(root, "supabase", "database"); + const schemaPath = path.join(dir, "public", "schema.sql"); + const manifestPath = path.join(dir, ".pgdelta-export.json"); + const output = nextOutput([ + { name: "public/schema.sql", sql: "create table public.example(id int);" }, + ]); + yield* write(dir, output); + const old = DateTime.toDate(DateTime.makeUnsafe({ year: 2020, month: 1, day: 1 })); + yield* fs.utimes(schemaPath, old, old); + yield* fs.utimes(manifestPath, old, old); + yield* write(dir, output); + const schemaInfo = yield* fs.stat(schemaPath); + const manifestInfo = yield* fs.stat(manifestPath); + expect( + Option.isSome(schemaInfo.mtime) ? schemaInfo.mtime.value.toISOString() : undefined, + ).toBe("2020-01-01T00:00:00.000Z"); + expect( + Option.isSome(manifestInfo.mtime) ? manifestInfo.mtime.value.toISOString() : undefined, + ).toBe("2020-01-01T00:00:00.000Z"); + }).pipe(Effect.provide(BunServices.layer)), + ); it.effect("rejects reserved and escaping export paths", () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-decl-write-" }); const reserved = yield* write( - join(tmp.current, "reserved"), + path.join(root, "reserved"), nextOutput([{ name: "_custom/generated.sql", sql: "select 1;" }]), ).pipe(Effect.flip); expect(reserved).toBeInstanceOf(LegacyDeclarativeWriteError); expect(reserved.message).toContain("reserved declarative schema path"); - const escaping = yield* write(join(tmp.current, "escaping"), { + const escaping = yield* write(path.join(root, "escaping"), { version: 1, mode: "declarative", files: [{ path: "../escape.sql", order: 0, statements: 0, sql: "x" }], }).pipe(Effect.flip); expect(escaping).toBeInstanceOf(LegacyDeclarativeWriteError); expect(escaping.message).toContain("unsafe declarative export path"); - }), + }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts index 19fbf800b1..75e47261d5 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts @@ -14,7 +14,7 @@ * is nothing to port. */ -import { Effect, Result, type FileSystem, type Path } from "effect"; +import { Config, Effect, Result, type FileSystem, type Path } from "effect"; import type { GlobalFlag } from "effect/unstable/cli"; import type * as HttpClient from "effect/unstable/http/HttpClient"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; @@ -61,6 +61,7 @@ import { } from "./legacy-pgdelta.apply.ts"; import { LegacyDeclarativeShadowDbError } from "./legacy-pgdelta.errors.ts"; import type { LegacyPgDeltaContext } from "../../../shared/legacy-pgdelta.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -127,7 +128,7 @@ export const legacyPrepareShadowSource = <E>( input: LegacyPrepareShadowSourceInput<E>, ): Effect.Effect< LegacyShadowSourceResult, - LegacyPrepareShadowSourceError | E, + LegacyPrepareShadowSourceError | E | Config.ConfigError, | Output | LegacyDockerRun | RuntimeInfo @@ -141,6 +142,7 @@ export const legacyPrepareShadowSource = <E>( // PRRT_kwDOErm0O86XL_oz) needs `CliArgs` to detect an explicit `--debug=false`, same as // `legacyResolveYes`/`legacyResolveExperimental`. | CliArgs + | LegacyViperEnv > => Effect.gen(function* () { const { containerId } = handle; @@ -385,9 +387,7 @@ function legacyGlobDeclaredSchemaPaths( for (const pattern of skipped) problems.push(`no files matched pattern: ${pattern}`); } if (problems.length > 0) { - return yield* Effect.fail( - new LegacyDeclarativeShadowDbError({ message: problems.join("\n") }), - ); + return yield* new LegacyDeclarativeShadowDbError({ message: problems.join("\n") }); } return result; }); diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts index 4bc35fd47f..82f5ba832b 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.unit.test.ts @@ -1,9 +1,6 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect"; import { legacyCleanSchemaPath, @@ -22,9 +19,18 @@ function pgDelta(overrides: Partial<LegacyPgDeltaTomlConfig> = {}): LegacyPgDelt }; } -function makeWorkdir(): string { - return mkdtempSync(join(tmpdir(), "legacy-shadow-source-")); -} +const stringifyJson = (value: unknown): string => + Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(value); + +const withWorkdir = <A, E, R>( + run: (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) => Effect.Effect<A, E, R>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "legacy-shadow-source-" }); + return yield* run(fs, path, workdir); + }).pipe(Effect.provide(BunServices.layer)); // Root bypasses POSIX permission bits, so chmod 000 wouldn't block readdir() there. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; @@ -165,627 +171,608 @@ describe("legacyCleanSchemaPath", () => { describe("legacyLoadDeclaredSchemas", () => { it.effect( "returns [] when neither schema_paths, an enabled pg-delta dir, nor supabase/schemas exist", - () => { - const workdir = makeWorkdir(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); - expect(result).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "falls back to sorted supabase/schemas/*.sql when no schema_paths/pg-delta dir apply", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas", "b.sql"), "select 2;\n"); - writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); - expect(result).toEqual(["supabase/schemas/a.sql", "supabase/schemas/b.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", "b.sql"), + "select 2;\n", + ); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", "a.sql"), + "select 1;\n", + ); + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/a.sql", "supabase/schemas/b.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "prefers the pg-delta declarative dir over supabase/schemas when pg-delta is enabled and the dir exists", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "database"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "database", "t.sql"), "select 1;\n"); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas", "unused.sql"), "select 2;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - [], - pgDelta({ - enabled: true, - declarativeSchemaPath: Option.some("supabase/database"), - }), - ); - expect(result).toEqual(["supabase/database/t.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase", "database"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "database", "t.sql"), + "select 1;\n", + ); + yield* fs.makeDirectory(path.join(workdir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", "unused.sql"), + "select 2;\n", + ); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ + enabled: true, + declarativeSchemaPath: Option.some("supabase/database"), + }), + ); + expect(result).toEqual(["supabase/database/t.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "prefers db.migrations.schema_paths over both the pg-delta dir and supabase/schemas", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); - mkdirSync(join(workdir, "supabase", "database"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "database", "unused.sql"), "select 2;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase", "custom"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "custom", "x.sql"), + "select 1;\n", + ); + yield* fs.makeDirectory(path.join(workdir, "supabase", "database"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "database", "unused.sql"), + "select 2;\n", + ); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom/*.sql"], + pgDelta({ + enabled: true, + declarativeSchemaPath: Option.some("supabase/database"), + }), + ); + expect(result).toEqual(["supabase/custom/x.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("fails when a literal (non-glob) schema_paths entry matches nothing", () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + const exit = yield* legacyLoadDeclaredSchemas( fs, path, workdir, - ["custom/*.sql"], - pgDelta({ - enabled: true, - declarativeSchemaPath: Option.some("supabase/database"), - }), - ); - expect(result).toEqual(["supabase/custom/x.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + ["missing.sql"], + pgDelta(), + ).pipe(Effect.exit); + expect(exit._tag).toBe("Failure"); + }).pipe(Effect.provide(BunServices.layer)), + ), ); - it.effect("fails when a literal (non-glob) schema_paths entry matches nothing", () => { - const workdir = makeWorkdir(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - ["missing.sql"], - pgDelta(), - ).pipe(Effect.exit); - expect(exit._tag).toBe("Failure"); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }); - it.effect( 'an empty schema_paths entry matches nothing, not the entire project (Go\'s fs.Glob(""))', - () => { - // Go's `io/fs.Glob` never matches an empty pattern — its literal-pattern branch calls - // `Stat(fsys, "")`, which fails on a real OS filesystem, so `Glob.SQLFiles` reports - // `no files matched pattern: ` for it (verified empirically against the real - // `config.Glob.SQLFiles` fed `""` over an `afero.NewOsFs()`). Without this guard, - // `legacyGlobPattern`'s literal-pattern branch resolves `""` to the workdir itself - // (which always exists) and recursively collects every `.sql` file in the project, - // including files well outside any declared schema path. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "migrations", "001_init.sql"), "select 1;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [""], pgDelta()).pipe( - Effect.exit, - ); - expect(exit._tag).toBe("Failure"); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `io/fs.Glob` never matches an empty pattern — its literal-pattern branch calls + // `Stat(fsys, "")`, which fails on a real OS filesystem, so `Glob.SQLFiles` reports + // `no files matched pattern: ` for it (verified empirically against the real + // `config.Glob.SQLFiles` fed `""` over an `afero.NewOsFs()`). Without this guard, + // `legacyGlobPattern`'s literal-pattern branch resolves `""` to the workdir itself + // (which always exists) and recursively collects every `.sql` file in the project, + // including files well outside any declared schema path. + yield* fs.makeDirectory(path.join(workdir, "supabase", "migrations"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "migrations", "001_init.sql"), + "select 1;\n", + ); + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [""], pgDelta()).pipe( + Effect.exit, + ); + expect(exit._tag).toBe("Failure"); + }).pipe(Effect.provide(BunServices.layer)), + ), ); - it.effect("a glob schema_paths entry matching nothing is silently skipped, not an error", () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - ["custom/*.sql", "empty-glob/*.sql"], - pgDelta(), - ); - expect(result).toEqual(["supabase/custom/x.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }); - - it.effect( - "on POSIX, a backslash in a schema_paths entry is a path.Match escape, not a separator (review: PRRT_kwDOErm0O86W7n90)", - () => { - // Go's `filepath.ToSlash` (`fs.Glob(fsys, filepath.ToSlash(pattern))`, - // `pkg/config/config.go:145`) is a byte-for-byte no-op on POSIX — only Windows's - // `filepath.Separator` is `\`. `path.Match` (what `fs.Glob` compiles down to) then - // treats an un-converted `\` as an escape metacharacter: `custom\x.sql` escapes the - // literal `x`, matching a FILE literally named `customx.sql` directly under - // `supabase/`, never the path-separated `supabase/custom/x.sql`. Verified empirically: - // `path.Match("custom\\x.sql", "customx.sql")` is `true` on darwin, while - // `path.Match("custom\\x.sql", "custom/x.sql")` never even reaches that filename (the - // pattern has no `/`, so it only lists `supabase/`, never descends into `custom/`). - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "customx.sql"), "select 1;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + it.effect("a glob schema_paths entry matching nothing is silently skipped, not an error", () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase", "custom"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); const result = yield* legacyLoadDeclaredSchemas( fs, path, workdir, - ["custom\\x.sql"], + ["custom/*.sql", "empty-glob/*.sql"], pgDelta(), ); - expect(result).toEqual(["supabase/customx.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + expect(result).toEqual(["supabase/custom/x.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect( + "on POSIX, a backslash in a schema_paths entry is a path.Match escape, not a separator (review: PRRT_kwDOErm0O86W7n90)", + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `filepath.ToSlash` (`fs.Glob(fsys, filepath.ToSlash(pattern))`, + // `pkg/config/config.go:145`) is a byte-for-byte no-op on POSIX — only Windows's + // `filepath.Separator` is `\`. `path.Match` (what `fs.Glob` compiles down to) then + // treats an un-converted `\` as an escape metacharacter: `custom\x.sql` escapes the + // literal `x`, matching a FILE literally named `customx.sql` directly under + // `supabase/`, never the path-separated `supabase/custom/x.sql`. Verified empirically: + // `path.Match("custom\\x.sql", "customx.sql")` is `true` on darwin, while + // `path.Match("custom\\x.sql", "custom/x.sql")` never even reaches that filename (the + // pattern has no `/`, so it only lists `supabase/`, never descends into `custom/`). + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "customx.sql"), "select 1;\n"); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom\\x.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/customx.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "on POSIX, a backslash-escaped glob metacharacter in schema_paths matches the literal filename (review: PRRT_kwDOErm0O86W7n90)", - () => { - // The specific case the review thread flagged: `path.Match("foo\\*.sql", "foo*.sql")` - // is `true` on darwin — the escaped `*` is a literal asterisk, matching a file named - // `foo*.sql`, not a glob that searches a `foo/` subdirectory. Before this fix, - // `legacyGlobDeclaredSchemaPaths` unconditionally rewrote the pattern to `foo/*.sql` - // ahead of globbing, which searches `foo/` instead and would report "no files matched" - // for this exact, valid Go config. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "foo*.sql"), "select 1;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - ["foo\\*.sql"], - pgDelta(), - ); - expect(result).toEqual(["supabase/foo*.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // The specific case the review thread flagged: `path.Match("foo\\*.sql", "foo*.sql")` + // is `true` on darwin — the escaped `*` is a literal asterisk, matching a file named + // `foo*.sql`, not a glob that searches a `foo/` subdirectory. Before this fix, + // `legacyGlobDeclaredSchemaPaths` unconditionally rewrote the pattern to `foo/*.sql` + // ahead of globbing, which searches `foo/` instead and would report "no files matched" + // for this exact, valid Go config. + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "foo*.sql"), "select 1;\n"); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["foo\\*.sql"], + pgDelta(), + ); + expect(result).toEqual(["supabase/foo*.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "dedupes a directory schema_paths entry with a trailing separator against a literal-file entry for the same file (review: PRRT_kwDOErm0O86XAlIr)", - () => { - // A RELATIVE trailing-slash entry gets `path.Join`-cleaned away by - // `legacyResolveSeedSqlPath` before it ever reaches the glob, matching Go's own - // `path.Join(builder.SupabaseDirPath, pattern)` resolution — so the bug is only - // reachable via an ABSOLUTE entry, which `legacyResolveSeedSqlPath` returns verbatim - // (Go's `Glob.files` never resolves an absolute entry either). Without the fix, the - // directory branch recorded the walked file as `<abs>/custom//x.sql` (raw template - // concatenation), which never matches the literal entry's `<abs>/custom/x.sql` in - // `seen`, so both were appended to `result` and the declarative apply would run the - // same file's SQL twice. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); - const absDirWithTrailingSlash = `${join(workdir, "supabase", "custom")}/`; - const absFile = join(workdir, "supabase", "custom", "x.sql"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - [absDirWithTrailingSlash, absFile], - pgDelta(), - ); - expect(result).toEqual([absFile]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // A RELATIVE trailing-slash entry gets `path.Join`-cleaned away by + // `legacyResolveSeedSqlPath` before it ever reaches the glob, matching Go's own + // `path.Join(builder.SupabaseDirPath, pattern)` resolution — so the bug is only + // reachable via an ABSOLUTE entry, which `legacyResolveSeedSqlPath` returns verbatim + // (Go's `Glob.files` never resolves an absolute entry either). Without the fix, the + // directory branch recorded the walked file as `<abs>/custom//x.sql` (raw template + // concatenation), which never matches the literal entry's `<abs>/custom/x.sql` in + // `seen`, so both were appended to `result` and the declarative apply would run the + // same file's SQL twice. + yield* fs.makeDirectory(path.join(workdir, "supabase", "custom"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "custom", "x.sql"), + "select 1;\n", + ); + const absDirWithTrailingSlash = `${path.join(workdir, "supabase", "custom")}/`; + const absFile = path.join(workdir, "supabase", "custom", "x.sql"); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [absDirWithTrailingSlash, absFile], + pgDelta(), + ); + expect(result).toEqual([absFile]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "excludes a symlinked .sql file from a recursively-matched schema_paths directory", - () => { - // Go's `entry.Type().IsRegular()` (`config.go:127`) is a no-follow check — a symlink - // is never "regular", so `walkMatchedDir` excludes it even when it resolves to a real - // `.sql` file. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); - const secretTarget = join(workdir, "outside.sql"); - writeFileSync(secretTarget, "select 2;\n"); - symlinkSync(secretTarget, join(workdir, "supabase", "custom", "linked.sql")); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); - expect(result).toEqual(["supabase/custom/real.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, - ); - - it.effect("excludes a symlinked .sql file from the supabase/schemas fallback walk", () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas", "real.sql"), "select 1;\n"); - const secretTarget = join(workdir, "outside.sql"); - writeFileSync(secretTarget, "select 2;\n"); - symlinkSync(secretTarget, join(workdir, "supabase", "schemas", "linked.sql")); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); - expect(result).toEqual(["supabase/schemas/real.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }); + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `entry.Type().IsRegular()` (`config.go:127`) is a no-follow check — a symlink + // is never "regular", so `walkMatchedDir` excludes it even when it resolves to a real + // `.sql` file. + yield* fs.makeDirectory(path.join(workdir, "supabase", "custom"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "custom", "real.sql"), + "select 1;\n", + ); + const secretTarget = path.join(workdir, "outside.sql"); + yield* fs.writeFileString(secretTarget, "select 2;\n"); + yield* fs.symlink(secretTarget, path.join(workdir, "supabase", "custom", "linked.sql")); + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); + expect(result).toEqual(["supabase/custom/real.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("excludes a symlinked .sql file from the supabase/schemas fallback walk", () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", "real.sql"), + "select 1;\n", + ); + const secretTarget = path.join(workdir, "outside.sql"); + yield* fs.writeFileString(secretTarget, "select 2;\n"); + yield* fs.symlink(secretTarget, path.join(workdir, "supabase", "schemas", "linked.sql")); + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual(["supabase/schemas/real.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); it.effect( "does not follow a symlinked subdirectory in a recursively-matched schema_paths directory", - () => { - // Go's `fs.WalkDir` (`walkMatchedDir`, `config.go:194-211`) is `Lstat`-based and never - // descends into a symlinked directory (`io/fs.WalkDir` doc: "WalkDir does not follow - // symbolic links found in directories") — a schema dir symlinking OUT of the configured - // schema tree must not leak the linked directory's files into the diff/pull target. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); - const outsideDir = join(workdir, "outside"); - mkdirSync(outsideDir, { recursive: true }); - writeFileSync(join(outsideDir, "secret.sql"), "select 2;\n"); - symlinkSync(outsideDir, join(workdir, "supabase", "custom", "linked-dir"), "dir"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); - expect(result).toEqual(["supabase/custom/real.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `fs.WalkDir` (`walkMatchedDir`, `config.go:194-211`) is `Lstat`-based and never + // descends into a symlinked directory (`io/fs.WalkDir` doc: "WalkDir does not follow + // symbolic links found in directories") — a schema dir symlinking OUT of the configured + // schema tree must not leak the linked directory's files into the diff/pull target. + yield* fs.makeDirectory(path.join(workdir, "supabase", "custom"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "custom", "real.sql"), + "select 1;\n", + ); + const outsideDir = path.join(workdir, "outside"); + yield* fs.makeDirectory(outsideDir, { recursive: true }); + yield* fs.writeFileString(path.join(outsideDir, "secret.sql"), "select 2;\n"); + yield* fs.symlink(outsideDir, path.join(workdir, "supabase", "custom", "linked-dir")); + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, ["custom"], pgDelta()); + expect(result).toEqual(["supabase/custom/real.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); - it.effect( - "does not follow a symlinked subdirectory in the supabase/schemas fallback walk", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas", "real.sql"), "select 1;\n"); - const outsideDir = join(workdir, "outside"); - mkdirSync(outsideDir, { recursive: true }); - writeFileSync(join(outsideDir, "secret.sql"), "select 2;\n"); - symlinkSync(outsideDir, join(workdir, "supabase", "schemas", "linked-dir"), "dir"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + it.effect("does not follow a symlinked subdirectory in the supabase/schemas fallback walk", () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", "real.sql"), + "select 1;\n", + ); + const outsideDir = path.join(workdir, "outside"); + yield* fs.makeDirectory(outsideDir, { recursive: true }); + yield* fs.writeFileString(path.join(outsideDir, "secret.sql"), "select 2;\n"); + yield* fs.symlink(outsideDir, path.join(workdir, "supabase", "schemas", "linked-dir")); const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); expect(result).toEqual(["supabase/schemas/real.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "falls back to supabase/schemas when the pg-delta declarative path exists but is a regular file", - () => { - // Go's `afero.DirExists` (`apps/cli-go/internal/db/diff/diff.go:63`) treats a non-directory - // path as absent, not present-but-unwalkable — a stray `supabase/database` FILE (e.g. left - // over from a previous config) must fall through to `supabase/schemas`, not make - // `legacyWalkSqlFilesSorted` try (and fail) to read a file as a directory. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "database"), "not a directory"); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - [], - pgDelta({ - enabled: true, - declarativeSchemaPath: Option.some("supabase/database"), - }), - ); - expect(result).toEqual(["supabase/schemas/a.sql"]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `afero.DirExists` (`apps/cli-go/internal/db/diff/diff.go:63`) treats a non-directory + // path as absent, not present-but-unwalkable — a stray `supabase/database` FILE (e.g. left + // over from a previous config) must fall through to `supabase/schemas`, not make + // `legacyWalkSqlFilesSorted` try (and fail) to read a file as a directory. + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "database"), "not a directory"); + yield* fs.makeDirectory(path.join(workdir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", "a.sql"), + "select 1;\n", + ); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ + enabled: true, + declarativeSchemaPath: Option.some("supabase/database"), + }), + ); + expect(result).toEqual(["supabase/schemas/a.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); - it.effect( - "returns [] when supabase/schemas exists but is a regular file, not a directory", - () => { - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas"), "not a directory"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + it.effect("returns [] when supabase/schemas exists but is a regular file, not a directory", () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "schemas"), "not a directory"); const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); expect(result).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "returns [] (does not follow) when the pg-delta declarative dir itself is a symlink", - () => { - // Go's `afero.Walk(fsys, declDir, ...)` Lstat's the ROOT before ever calling `walkFn` - // (`afero`'s own `Walk`/`lstatIfPossible`) — a symlinked root is treated as a - // non-directory and produces zero files, silently, never descending into the target. - // The PRECEDING `afero.DirExists`-equivalent existence check (which follows symlinks, - // matching Go's own `fs.Stat`-based `DirExists`) reports the symlinked dir as present, so - // only the WALK itself (not the existence check) must reject it. - const workdir = makeWorkdir(); - const realDir = join(workdir, "real-database"); - mkdirSync(realDir, { recursive: true }); - writeFileSync(join(realDir, "t.sql"), "select 1;\n"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - symlinkSync(realDir, join(workdir, "supabase", "database"), "dir"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - [], - pgDelta({ - enabled: true, - declarativeSchemaPath: Option.some("supabase/database"), - }), - ); + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `afero.Walk(fsys, declDir, ...)` Lstat's the ROOT before ever calling `walkFn` + // (`afero`'s own `Walk`/`lstatIfPossible`) — a symlinked root is treated as a + // non-directory and produces zero files, silently, never descending into the target. + // The PRECEDING `afero.DirExists`-equivalent existence check (which follows symlinks, + // matching Go's own `fs.Stat`-based `DirExists`) reports the symlinked dir as present, so + // only the WALK itself (not the existence check) must reject it. + const realDir = path.join(workdir, "real-database"); + yield* fs.makeDirectory(realDir, { recursive: true }); + yield* fs.writeFileString(path.join(realDir, "t.sql"), "select 1;\n"); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.symlink(realDir, path.join(workdir, "supabase", "database")); + const result = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ + enabled: true, + declarativeSchemaPath: Option.some("supabase/database"), + }), + ); + expect(result).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("returns [] (does not follow) when supabase/schemas itself is a symlink", () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + const realDir = path.join(workdir, "real-schemas"); + yield* fs.makeDirectory(realDir, { recursive: true }); + yield* fs.writeFileString(path.join(realDir, "t.sql"), "select 1;\n"); + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.symlink(realDir, path.join(workdir, "supabase", "schemas")); + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); expect(result).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, - ); - - it.effect("returns [] (does not follow) when supabase/schemas itself is a symlink", () => { - const workdir = makeWorkdir(); - const realDir = join(workdir, "real-schemas"); - mkdirSync(realDir, { recursive: true }); - writeFileSync(join(realDir, "t.sql"), "select 1;\n"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - symlinkSync(realDir, join(workdir, "supabase", "schemas"), "dir"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); - expect(result).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); it.effect( "sorts declared schema paths by UTF-8 byte order, not JS's default UTF-16 code-unit order", - () => { - // A supplementary-plane character (U+1F600, a surrogate pair in UTF-16) alongside a BMP - // private-use character (U+E000) is the textbook case where JS's default `.sort()` - // (UTF-16 code units) disagrees with Go's `sort.Strings` (UTF-8 bytes, which preserves - // codepoint order): JS ranks the surrogate pair first (0xD800 < 0xE000), Go ranks the - // supplementary-plane codepoint last (it's numerically > U+FFFF). Verified empirically - // against `Buffer.compare` on the two filenames' UTF-8 encodings. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - const supplementary = "a\u{1F600}.sql"; - const privateUse = "a.sql"; - writeFileSync(join(workdir, "supabase", "schemas", supplementary), "select 1;\n"); - writeFileSync(join(workdir, "supabase", "schemas", privateUse), "select 2;\n"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); - expect(result).toEqual([ - `supabase/schemas/${privateUse}`, - `supabase/schemas/${supplementary}`, - ]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // A supplementary-plane character (U+1F600, a surrogate pair in UTF-16) alongside a BMP + // private-use character (U+E000) is the textbook case where JS's default `.sort()` + // (UTF-16 code units) disagrees with Go's `sort.Strings` (UTF-8 bytes, which preserves + // codepoint order): JS ranks the surrogate pair first (0xD800 < 0xE000), Go ranks the + // supplementary-plane codepoint last (it's numerically > U+FFFF). Verified empirically + // against `Buffer.compare` on the two filenames' UTF-8 encodings. + const supplementary = "a\u{1F600}.sql"; + const privateUse = "a.sql"; + yield* fs.makeDirectory(path.join(workdir, "supabase", "schemas"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", supplementary), + "select 1;\n", + ); + yield* fs.writeFileString( + path.join(workdir, "supabase", "schemas", privateUse), + "select 2;\n", + ); + const result = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()); + expect(result).toEqual([ + `supabase/schemas/${privateUse}`, + `supabase/schemas/${supplementary}`, + ]); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect( "propagates (rather than silently drops) a per-entry stat failure during the pg-delta/schemas walk", - () => { - // Both Go walkers (`afero.Walk`, `fs.WalkDir`) pass a per-entry stat/lstat error to their - // callback, which returns it and aborts the whole walk — an entry that can't be statted - // after its parent was listed (permissions, I/O error, a concurrent filesystem change) - // must not be silently omitted, which could build an incomplete declarative target. - const workdir = makeWorkdir(); - mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); - const brokenAbs = join(workdir, "supabase", "schemas", "broken.sql"); - writeFileSync(brokenAbs, "select 2;\n"); - const statFs = Layer.effect( - FileSystem.FileSystem, - Effect.map(FileSystem.FileSystem, (real) => ({ - ...real, - stat: (statPath: string) => - statPath === brokenAbs - ? Effect.fail( - PlatformError.systemError({ - _tag: "Unknown", - module: "FileSystem", - method: "stat", - description: "simulated stat failure", - pathOrDescriptor: statPath, - }), - ) - : real.stat(statPath), - })), - ).pipe(Layer.provideMerge(BunServices.layer)); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( - Effect.exit, - ); - expect(exit._tag).toBe("Failure"); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(statFs)); - }, + () => + withWorkdir((realFs, path, workdir) => { + // Both Go walkers (`afero.Walk`, `fs.WalkDir`) pass a per-entry stat/lstat error to their + // callback, which returns it and aborts the whole walk — an entry that can't be statted + // after its parent was listed (permissions, I/O error, a concurrent filesystem change) + // must not be silently omitted, which could build an incomplete declarative target. + const brokenAbs = path.join(workdir, "supabase", "schemas", "broken.sql"); + const statFs = Layer.effect( + FileSystem.FileSystem, + Effect.succeed({ + ...realFs, + stat: (statPath: string) => + statPath === brokenAbs + ? Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "stat", + description: "simulated stat failure", + pathOrDescriptor: statPath, + }), + ) + : realFs.stat(statPath), + }), + ).pipe(Layer.provideMerge(BunServices.layer)); + return Effect.gen(function* () { + yield* realFs.makeDirectory(path.join(workdir, "supabase", "schemas"), { + recursive: true, + }); + yield* realFs.writeFileString( + path.join(workdir, "supabase", "schemas", "a.sql"), + "select 1;\n", + ); + yield* realFs.writeFileString(brokenAbs, "select 2;\n"); + const fs = yield* FileSystem.FileSystem; + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.exit, + ); + expect(exit._tag).toBe("Failure"); + }).pipe(Effect.provide(statFs)); + }), ); it.effect.skipIf(isRoot)( "fails (rather than silently treating as empty) when a matched schema directory can't be read, and keeps the underlying cause in the message", - () => { - // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` - // error as `failed to walk matched directory: <err>` — an unreadable directory must - // surface as a failure, not silently contribute zero files (which could compare a - // local-target diff against the wrong target or generate an incomplete migration), and - // the reported message must carry the real underlying error (permission denied, here), - // not just the directory name — otherwise a user can't tell WHY the walk failed. - const workdir = makeWorkdir(); - const locked = join(workdir, "supabase", "locked"); - mkdirSync(locked, { recursive: true }); - chmodSync(locked, 0o000); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - ["locked"], - pgDelta(), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); - expect(errorJson).toContain("failed to walk matched directory:"); - expect(errorJson).not.toContain("failed to walk matched directory: locked"); - } - chmodSync(locked, 0o755); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` + // error as `failed to walk matched directory: <err>` — an unreadable directory must + // surface as a failure, not silently contribute zero files (which could compare a + // local-target diff against the wrong target or generate an incomplete migration), and + // the reported message must carry the real underlying error (permission denied, here), + // not just the directory name — otherwise a user can't tell WHY the walk failed. + const locked = path.join(workdir, "supabase", "locked"); + yield* fs.makeDirectory(locked, { recursive: true }); + yield* fs.chmod(locked, 0o000); + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["locked"], + pgDelta(), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = stringifyJson(exit.cause); + expect(errorJson).toContain("failed to walk matched directory:"); + expect(errorJson).not.toContain("failed to walk matched directory: locked"); + } + yield* fs.chmod(locked, 0o755); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect.skipIf(isRoot)( "visits sibling directories in UTF-8 byte order, not JS's default UTF-16 order, so the reported failure matches Go's (review: PRRT_kwDOErm0O86XAlIo)", - () => { - // `["dir\u{1F600}", "dir\u{E000}"].sort()` (JS default, UTF-16 code-unit order) puts the - // supplementary-plane name FIRST — its lead surrogate (0xD83D) is less than the - // private-use code unit (0xE000). Byte order (Go's `sort.Strings`/`bytealg.CompareString`, - // what `legacyCompareUtf8Bytes` reproduces) disagrees: U+1F600 encodes to a LARGER first - // UTF-8 byte (0xF0) than U+E000 (0xEE), so the private-use name sorts first instead. - // Both subdirectories are unreadable, so whichever the walk visits FIRST is the one whose - // `EACCES` failure aborts the whole walk (Effect.gen never reaches the second entry) — - // its path, not the other one's, must appear in the resulting error. - const workdir = makeWorkdir(); - const matched = join(workdir, "supabase", "custom"); - const utf16First = join(matched, "dir\u{1F600}"); - const byteOrderFirst = join(matched, "dir\u{E000}"); - mkdirSync(utf16First, { recursive: true }); - mkdirSync(byteOrderFirst, { recursive: true }); - chmodSync(utf16First, 0o000); - chmodSync(byteOrderFirst, 0o000); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - ["custom"], - pgDelta(), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); - expect(errorJson).toContain(byteOrderFirst); - expect(errorJson).not.toContain(utf16First); - } - chmodSync(utf16First, 0o755); - chmodSync(byteOrderFirst, 0o755); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // `["dir\u{1F600}", "dir\u{E000}"].sort()` (JS default, UTF-16 code-unit order) puts the + // supplementary-plane name FIRST — its lead surrogate (0xD83D) is less than the + // private-use code unit (0xE000). Byte order (Go's `sort.Strings`/`bytealg.CompareString`, + // what `legacyCompareUtf8Bytes` reproduces) disagrees: U+1F600 encodes to a LARGER first + // UTF-8 byte (0xF0) than U+E000 (0xEE), so the private-use name sorts first instead. + // Both subdirectories are unreadable, so whichever the walk visits FIRST is the one whose + // `EACCES` failure aborts the whole walk (Effect.gen never reaches the second entry) — + // its path, not the other one's, must appear in the resulting error. + const matched = path.join(workdir, "supabase", "custom"); + const utf16First = path.join(matched, "dir\u{1F600}"); + const byteOrderFirst = path.join(matched, "dir\u{E000}"); + yield* fs.makeDirectory(utf16First, { recursive: true }); + yield* fs.makeDirectory(byteOrderFirst, { recursive: true }); + yield* fs.chmod(utf16First, 0o000); + yield* fs.chmod(byteOrderFirst, 0o000); + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + ["custom"], + pgDelta(), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = stringifyJson(exit.cause); + expect(errorJson).toContain(byteOrderFirst); + expect(errorJson).not.toContain(utf16First); + } + yield* fs.chmod(utf16First, 0o755); + yield* fs.chmod(byteOrderFirst, 0o755); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect.skipIf(isRoot)( "reports the pg-delta declarative dir walk failure as 'failed to walk declarative dir', not the generic 'failed to walk dir'", - () => { - // Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`) wraps the - // SAME `afero.Walk` failure with a DIFFERENT prefix per source: the pg-delta declarative - // dir branch reports `failed to walk declarative dir: %w`, while the `supabase/schemas` - // fallback (covered by the sibling test below) reports `failed to walk dir: %w` — both - // walks share `legacyWalkSqlFilesSorted`, which must be told which source it's walking. - const workdir = makeWorkdir(); - const declDir = join(workdir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - chmodSync(declDir, 0o000); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas( - fs, - path, - workdir, - [], - pgDelta({ - enabled: true, - declarativeSchemaPath: Option.some("supabase/database"), - }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); - expect(errorJson).toContain("failed to walk declarative dir:"); - expect(errorJson).not.toContain("failed to walk dir:"); - } - chmodSync(declDir, 0o755); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + // Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`) wraps the + // SAME `afero.Walk` failure with a DIFFERENT prefix per source: the pg-delta declarative + // dir branch reports `failed to walk declarative dir: %w`, while the `supabase/schemas` + // fallback (covered by the sibling test below) reports `failed to walk dir: %w` — both + // walks share `legacyWalkSqlFilesSorted`, which must be told which source it's walking. + const declDir = path.join(workdir, "supabase", "database"); + yield* fs.makeDirectory(declDir, { recursive: true }); + yield* fs.chmod(declDir, 0o000); + const exit = yield* legacyLoadDeclaredSchemas( + fs, + path, + workdir, + [], + pgDelta({ + enabled: true, + declarativeSchemaPath: Option.some("supabase/database"), + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = stringifyJson(exit.cause); + expect(errorJson).toContain("failed to walk declarative dir:"); + expect(errorJson).not.toContain("failed to walk dir:"); + } + yield* fs.chmod(declDir, 0o755); + }).pipe(Effect.provide(BunServices.layer)), + ), ); it.effect.skipIf(isRoot)( "reports the supabase/schemas fallback walk failure as 'failed to walk dir', not the declarative-dir prefix", - () => { - const workdir = makeWorkdir(); - const schemasDir = join(workdir, "supabase", "schemas"); - mkdirSync(schemasDir, { recursive: true }); - chmodSync(schemasDir, 0o000); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( - Effect.exit, - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); - expect(errorJson).toContain("failed to walk dir:"); - expect(errorJson).not.toContain("failed to walk declarative dir:"); - } - chmodSync(schemasDir, 0o755); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }, + () => + withWorkdir((fs, path, workdir) => + Effect.gen(function* () { + const schemasDir = path.join(workdir, "supabase", "schemas"); + yield* fs.makeDirectory(schemasDir, { recursive: true }); + yield* fs.chmod(schemasDir, 0o000); + const exit = yield* legacyLoadDeclaredSchemas(fs, path, workdir, [], pgDelta()).pipe( + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = stringifyJson(exit.cause); + expect(errorJson).toContain("failed to walk dir:"); + expect(errorJson).not.toContain("failed to walk declarative dir:"); + } + yield* fs.chmod(schemasDir, 0o755); + }).pipe(Effect.provide(BunServices.layer)), + ), ); }); diff --git a/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts index 46ffcec0b3..818b5c8816 100644 --- a/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this e2e test uses Vitest's Promise surface to drive the real CLI. import { describe, expect, test } from "vitest"; import { diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 5f5244be1f..89c2ec7ed9 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -1,9 +1,20 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + PlatformError, + Sink, + Stream, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -33,6 +44,7 @@ import { type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../../shared/legacy-docker-run.layer.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { LegacyEdgeRuntimeScript, @@ -74,14 +86,12 @@ function mockContainerCliSpawner(route: (args: ReadonlyArray<string>) => RouteRe spawned.push({ args }); if (command._tag !== "StandardCommand") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } const result = route(args); @@ -121,7 +131,7 @@ function containerNameFromCreateArgs(args: ReadonlyArray<string>): string { } function fakeContainerId(name: string): string { - return [...name] + return Array.from(name) .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) .join("") .padEnd(64, "0") @@ -258,10 +268,63 @@ function fakeDbSession() { } const tempRoot = useLegacyTempWorkdir("supabase-db-start-int-"); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const pendingWrites = new Map< + string, + Array<{ readonly path: string; readonly contents: string }> +>(); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); + +function formatCause(cause: Cause.Cause<unknown>) { + return Formatter.formatJson(cause); +} + +function queueWrite(path: string, contents: string) { + const workdir = fixturePath.dirname(fixturePath.dirname(path)); + const writes = pendingWrites.get(workdir) ?? []; + writes.push({ path, contents }); + pendingWrites.set(workdir, writes); +} + +function writeFileSync(path: string, contents: string) { + queueWrite(path, contents); +} + +function flushFixtureWrites(workdir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const writes = pendingWrites.get(workdir) ?? []; + for (const write of writes) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingWrites.delete(workdir); + }); +} + +function readFile(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }).pipe(Effect.provide(BunServices.layer)); +} + +function readDirectory(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(path); + }).pipe(Effect.provide(BunServices.layer)); +} + +function existsPath(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + }).pipe(Effect.provide(BunServices.layer)); +} function writeConfig(workdir: string, contents: string) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), contents); + queueWrite(join(workdir, "supabase", "config.toml"), contents); } interface SetupOpts { @@ -280,6 +343,7 @@ interface SetupOpts { readonly experimental?: boolean; /** `--debug`. Defaults to `false`. */ readonly debug?: boolean; + readonly env?: Readonly<Record<string, string | undefined>>; /** `LegacyEdgeRuntimeScript`'s mocked stdout for the pg-delta catalog-export call (`db-setup.ts`'s `legacyTryCacheMigrationsCatalog`). Only ever reached on a fresh volume with pg-delta enabled. */ readonly catalogStdout?: string; /** Fails the mocked catalog-export call with this message instead of succeeding. */ @@ -295,7 +359,7 @@ function setup(opts: SetupOpts = {}) { if (opts.skipConfig !== true) { writeConfig(workdir, opts.configContents ?? 'project_id = "test"\n'); if (opts.projectEnvContents !== undefined) { - writeFileSync(join(workdir, "supabase", ".env"), opts.projectEnvContents); + queueWrite(join(workdir, "supabase", ".env"), opts.projectEnvContents); } } const out = mockOutput({ format: opts.format ?? "text" }); @@ -326,6 +390,10 @@ function setup(opts: SetupOpts = {}) { requireSsl: () => Effect.succeed(false), requireSslForHost: () => Effect.succeed(false), }); + const providerEnv: Record<string, string> = {}; + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (value !== undefined) providerEnv[key] = value; + } let connectAttempts = 0; const connectFailures = opts.connectFailures ?? 0; @@ -347,7 +415,12 @@ function setup(opts: SetupOpts = {}) { }); const layer = Layer.mergeAll( - BunServices.layer, + BunServices.layer.pipe( + Layer.tap((context) => flushFixtureWrites(workdir).pipe(Effect.provideContext(context))), + ), + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: providerEnv, preserveEmptyStrings: true }), + ), out.layer, cliConfig, telemetry.layer, @@ -387,10 +460,6 @@ const currentBranchPath = (workdir: string) => join(workdir, "supabase", ".branches", "_current_branch"); describe("legacy db start", () => { - afterEach(() => { - delete process.env["SUPABASE_NETWORK_ID"]; - }); - it.live("reports an already-running database without starting a container", () => { const { layer, out, telemetry, child } = setup({ running: true }); return Effect.gen(function* () { @@ -401,7 +470,7 @@ describe("legacy db start", () => { // `initCurrentBranch` is inside `legacyStartDatabase`, never reached on // the already-running short-circuit — the already-running check // returns before `legacyStartDatabase` is ever called. - expect(existsSync(currentBranchPath(tempRoot.current))).toBe(false); + expect(yield* existsPath(currentBranchPath(tempRoot.current))).toBe(false); }); }); @@ -417,7 +486,7 @@ describe("legacy db start", () => { expect(out.stderrText).toContain("Initialising schema..."); // Default config: realtime, storage, and auth are all enabled (PG >= 15 default). expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); expect(out.stderrText).not.toContain("Finished"); }); }, @@ -430,7 +499,7 @@ describe("legacy db start", () => { return Effect.gen(function* () { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(s.layer)); expect(s.connectAttempts).toBe(3); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }); }, 15_000, @@ -463,7 +532,7 @@ describe("legacy db start", () => { // `LegacyDbConnection` session — no PG15+ one-shot `docker run --rm` migrate jobs at all. expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); expect(dbSession.calls.length).toBeGreaterThan(0); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }); }, ); @@ -492,7 +561,7 @@ describe("legacy db start", () => { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); // Default config: storage and auth stay enabled — only the realtime job is skipped. expect(dbSetupJobCalls(child.spawned)).toHaveLength(2); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }).pipe( Effect.ensuring( Effect.sync(() => { @@ -519,7 +588,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(formatCause(exit.cause)).toContain("LegacyDbConfigLoadError"); } // The container was already created/started/healthy by the time JWKS resolution runs // (deep inside the fresh-volume setup step) — the rollback still tears it down. @@ -550,11 +619,11 @@ describe("legacy db start", () => { // catalog cache runs immediately after the migrate-and-seed step. expect(edgeRunCalls).toHaveLength(1); const tempDir = join(tempRoot.current, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => + const catalogFiles = (yield* readDirectory(tempDir)).filter((name) => name.startsWith("catalog-local-migrations-"), ); expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); + expect(yield* readFile(join(tempDir, catalogFiles[0]!))).toBe('{"snapshot":"ok"}'); }); }, ); @@ -574,7 +643,7 @@ describe("legacy db start", () => { expect(out.stderrText).toContain( "Warning: failed to cache migrations catalog: edge-runtime script produced no output", ); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }); }, ); @@ -587,7 +656,9 @@ describe("legacy db start", () => { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(edgeRunCalls).toHaveLength(0); expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - expect(existsSync(join(tempRoot.current, "supabase", ".temp", "pgdelta"))).toBe(false); + expect(yield* existsPath(join(tempRoot.current, "supabase", ".temp", "pgdelta"))).toBe( + false, + ); }); }, ); @@ -611,7 +682,7 @@ describe("legacy db start", () => { expect(dbSession.calls.some((call) => call.sql.includes(PG_NET_DROP_FINGERPRINT))).toBe( true, ); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }); }, ); @@ -647,7 +718,7 @@ describe("legacy db start", () => { "/abs/host/backup.sql:/etc/backup.sql:ro", ); expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }); }, ); @@ -725,7 +796,7 @@ describe("legacy db start", () => { // the negative half. expect(volumePruneWasAttempted(child.spawned)).toBe(true); // The health-check timeout aborts before `SetupLocalDatabase`/`initCurrentBranch` ever run. - expect(existsSync(currentBranchPath(tempRoot.current))).toBe(false); + expect(yield* existsPath(currentBranchPath(tempRoot.current))).toBe(false); }); }); @@ -738,8 +809,6 @@ describe("legacy db start", () => { // still gated the rollback's `Pruned …:` stderr reports. Delete any shell `SUPABASE_DEBUG` // first: shell *presence* (even `false`) suppresses the project value entirely, per // `legacyViperEnvBoolWithProjectFallback`'s own semantics. - const previous = process.env["SUPABASE_DEBUG"]; - delete process.env["SUPABASE_DEBUG"]; const { layer, child } = setup({ configContents: 'project_id = "test"\n[db]\nhealth_timeout = "1s"\n', route: freshVolumeRoute(defaultRoute({ neverHealthy: true })), @@ -763,8 +832,6 @@ describe("legacy db start", () => { Effect.ensuring( Effect.sync(() => { globalThis.process.stderr.write = originalWrite; - if (previous === undefined) delete process.env["SUPABASE_DEBUG"]; - else process.env["SUPABASE_DEBUG"] = previous; }), ), ); @@ -785,7 +852,7 @@ describe("legacy db start", () => { // this test only asserts the command-level outcome that's specific to `--from-backup`. yield* legacyDbStart(flags("/abs/host/backup.sql")).pipe(Effect.provide(layer)); expect(rollbackWasAttempted(child.spawned)).toBe(false); - expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); + expect(yield* readFile(currentBranchPath(tempRoot.current))).toBe("main"); }); }, ); @@ -807,7 +874,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(formatCause(exit.cause)).toContain("LegacyDbConfigLoadError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }); @@ -820,7 +887,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to load config"); + expect(formatCause(exit.cause)).toContain("failed to load config"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); expect(telemetry.flushed).toBe(true); @@ -836,7 +903,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to parse config: missing private key"); + expect(formatCause(exit.cause)).toContain("failed to parse config: missing private key"); } expect(out.stderrText).not.toContain("already running"); }); @@ -866,8 +933,10 @@ describe("legacy db start", () => { // fallback, read fresh at its own call site — well after the config's // dotenv pass — so a shell/project-dotenv `SUPABASE_NETWORK_ID` is // effective when the flag itself is omitted (review: PRRT_kwDOErm0O86VlqIL). - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); + const { layer, child } = setup({ + env: { SUPABASE_NETWORK_ID: "env-network" }, + route: freshVolumeRoute(defaultRoute()), + }); return Effect.gen(function* () { yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect( @@ -914,7 +983,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(formatCause(exit.cause)).toContain("LegacyDbConfigLoadError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }); @@ -944,7 +1013,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain(dottedFieldPath); } @@ -970,7 +1039,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.rate_limit"); } @@ -1007,7 +1076,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain(dottedFieldPath); } @@ -1035,7 +1104,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain(dottedFieldPath); } @@ -1064,7 +1133,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain(dottedFieldPath); } @@ -1092,7 +1161,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("storage.enabled"); } @@ -1124,7 +1193,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain(dottedFieldPath); } @@ -1154,7 +1223,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("Invalid config for studio.api_url"); } @@ -1182,7 +1251,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("Missing required field in config: local_smtp.port"); } @@ -1210,7 +1279,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.jwt_expiry"); } @@ -1235,7 +1304,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("api.port"); } @@ -1277,7 +1346,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain(dottedFieldPath); } @@ -1304,7 +1373,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.passkey"); } @@ -1330,7 +1399,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.external"); } @@ -1364,7 +1433,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.hook"); } @@ -1396,7 +1465,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.email.smtp"); } @@ -1451,7 +1520,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("storage.image_transformation.enabled"); } @@ -1501,7 +1570,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("db.ssl_enforcement.enabled"); } @@ -1529,7 +1598,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("db.ssl_enforcement.enabled"); } @@ -1581,7 +1650,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain( "Webhooks cannot be deactivated. [experimental.webhooks] enabled can either be true or left undefined", @@ -1696,7 +1765,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = formatCause(exit.cause); expect(message).toContain("LegacyDbConfigLoadError"); expect(message).toContain("auth.email.max_frequency"); } @@ -1757,7 +1826,7 @@ describe("legacy db start", () => { const exit = yield* legacyDbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to inspect service"); + expect(formatCause(exit.cause)).toContain("failed to inspect service"); } }); }); diff --git a/apps/cli/src/legacy/commands/db/test/test.integration.test.ts b/apps/cli/src/legacy/commands/db/test/test.integration.test.ts index 2e18a09c61..87f3a5a05b 100644 --- a/apps/cli/src/legacy/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/test/test.integration.test.ts @@ -35,7 +35,7 @@ */ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { ConfigProvider, Effect, Layer, Option, Stdio } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { @@ -45,13 +45,14 @@ import { mockTelemetryRuntime, mockTty, } from "../../../../../tests/helpers/mocks.ts"; +import { alwaysReadyHttpClientLayer } from "../../../../../tests/helpers/legacy-local-reset.ts"; import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; import { LEGACY_GLOBAL_FLAGS, @@ -84,6 +85,11 @@ import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe. import { legacyRunTestDbCommand } from "../../../shared/legacy-test-db.command-handler.ts"; import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; import { legacyDbCommand } from "../db.command.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../../shared/legacy-local-gateway-http-client.ts"; + +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); const LOCAL_CONN: LegacyPgConnInput = { host: "127.0.0.1", @@ -193,6 +199,7 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const connection = mockDbConnection(); const docker = mockDockerRun({ exitCode: opts.exitCode, stdout: opts.stdout }); + const configProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); const args = ["db", "test"]; const layer = Layer.mergeAll( out.layer, @@ -211,6 +218,8 @@ function setup(opts: SetupOpts = {}) { Stdio.layerTest({ args: Effect.succeed(args) }), commandRuntimeLayer(["db", "test"]), BunServices.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), ); return { layer, out, analytics, processControl, connection, docker }; } @@ -287,10 +296,12 @@ describe("legacy db test (alias) integration", () => { requireSslForHost: () => Effect.die("LegacyPgDeltaSslProbe not needed for `db test` dispatch"), }), + makeLegacyViperEnvLayer(), + legacyLocalGatewayHttpClientTestLayer(alwaysReadyHttpClientLayer), ); const root = Command.make("supabase").pipe( - Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), Command.withSubcommands([legacyDbCommand]), + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), ); return Effect.gen(function* () { yield* Effect.exit(Command.runWith(root, { version: "0.0.0-test" })(args)); diff --git a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts index d12f40f0d5..8010bb6c52 100644 --- a/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/activate/activate.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -141,7 +141,9 @@ describe("legacy domains activate integration", () => { const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("unexpected activate hostname status 503"); + expect(Formatter.formatJson(exit.cause)).toContain( + "unexpected activate hostname status 503", + ); } expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -153,7 +155,7 @@ describe("legacy domains activate integration", () => { const exit = yield* Effect.exit(legacyDomainsActivate(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to activate custom hostname"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to activate custom hostname"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts index 0df1234389..ec8cf01dee 100644 --- a/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/create/create.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -166,7 +166,7 @@ describe("legacy domains create integration", () => { const exit = yield* Effect.exit(legacyDomainsCreate(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDomainsCnameError"); expect(json).toContain("but it failed to resolve"); } @@ -180,7 +180,9 @@ describe("legacy domains create integration", () => { const exit = yield* Effect.exit(legacyDomainsCreate(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to locate appropriate CNAME record"); + expect(Formatter.formatJson(exit.cause)).toContain( + "failed to locate appropriate CNAME record", + ); } expect(postedToInitialize(api)).toBe(false); }).pipe(Effect.provide(layer)); @@ -192,7 +194,7 @@ describe("legacy domains create integration", () => { const exit = yield* Effect.exit(legacyDomainsCreate(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "but it is currently set to 'wrong.example.com.'", ); } @@ -208,7 +210,7 @@ describe("legacy domains create integration", () => { const exit = yield* Effect.exit(legacyDomainsCreate(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("but it failed to resolve"); expect(json).toContain("unexpected DNS query status 500"); } @@ -258,7 +260,7 @@ describe("legacy domains create integration", () => { const exit = yield* Effect.exit(legacyDomainsCreate(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("unexpected create hostname status 503"); + expect(Formatter.formatJson(exit.cause)).toContain("unexpected create hostname status 503"); } }).pipe(Effect.provide(layer)); }); @@ -269,7 +271,7 @@ describe("legacy domains create integration", () => { const exit = yield* Effect.exit(legacyDomainsCreate(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to create custom hostname"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to create custom hostname"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts index 999fd16e7c..0e2d9260f0 100644 --- a/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/delete/delete.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -99,7 +99,7 @@ describe("legacy domains delete integration", () => { const exit = yield* Effect.exit(legacyDomainsDelete(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("unexpected delete hostname status 503"); + expect(Formatter.formatJson(exit.cause)).toContain("unexpected delete hostname status 503"); } expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); @@ -112,7 +112,7 @@ describe("legacy domains delete integration", () => { const exit = yield* Effect.exit(legacyDomainsDelete(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to delete custom hostname"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to delete custom hostname"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.ts b/apps/cli/src/legacy/commands/domains/domains.cname.ts index c3fc24a88b..e307fcc770 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.ts @@ -125,10 +125,8 @@ export const verifyLegacyCname = Effect.fnUntraced(function* (args: { ); if (resolved !== expected) { - return yield* Effect.fail( - new LegacyDomainsCnameError({ - message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it is currently set to '${resolved}'`, - }), - ); + return yield* new LegacyDomainsCnameError({ + message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it is currently set to '${resolved}'`, + }); } }); diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts index 84df23ce9a..30f8c394d3 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -1,6 +1,6 @@ -import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; +import { V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter, Schema } from "effect"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -125,7 +125,9 @@ describe("legacy domains get integration", () => { const { layer, out } = setup({ goOutput: "json", response }); return Effect.gen(function* () { yield* legacyDomainsGet(baseFlags); - const parsed = JSON.parse(out.stdoutText) as typeof V1GetHostnameConfigOutput.Type; + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(V1GetHostnameConfigOutput))( + out.stdoutText, + ); expect(parsed.data.result.ownership_verification).toEqual({ type: "", name: "", value: "" }); expect(parsed.data.result.ssl.validation_records).toEqual([]); }).pipe(Effect.provide(layer)); @@ -155,7 +157,9 @@ describe("legacy domains get integration", () => { const { layer, out } = setup({ goOutput: "json", response }); return Effect.gen(function* () { yield* legacyDomainsGet(baseFlags); - const parsed = JSON.parse(out.stdoutText) as Record<string, unknown>; + const parsed = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), + )(out.stdoutText); expect(parsed.status).toBe(""); expect(parsed.custom_hostname).toBe(""); expect(parsed.data).toMatchObject({ @@ -294,7 +298,7 @@ describe("legacy domains get integration", () => { const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDomainsUnexpectedStatusError"); expect(json).toContain("unexpected get hostname status 503"); } @@ -361,7 +365,7 @@ describe("legacy domains get integration", () => { const exit = yield* Effect.exit(legacyDomainsGet(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDomainsNetworkError"); expect(json).toContain("failed to get custom hostname"); } diff --git a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts index e6dfddd66e..94c04f2a34 100644 --- a/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/reverify/reverify.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -140,7 +140,9 @@ describe("legacy domains reverify integration", () => { const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("unexpected re-verify hostname status 503"); + expect(Formatter.formatJson(exit.cause)).toContain( + "unexpected re-verify hostname status 503", + ); } expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -152,7 +154,7 @@ describe("legacy domains reverify integration", () => { const exit = yield* Effect.exit(legacyDomainsReverify(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to re-verify custom hostname"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to re-verify custom hostname"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts b/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts index 528f35dca9..1b7a8dbc01 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts @@ -11,14 +11,14 @@ describe("supabase encryption (legacy)", () => { test( "get-root-key without a resolvable project ref exits non-zero with the not-linked message", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["encryption", "get-root-key"], { + () => + runSupabase(["encryption", "get-root-key"], { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, - }); - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); + }), ); // Validates the piped-stdin read path reaches the resolver in a real @@ -26,14 +26,14 @@ describe("supabase encryption (legacy)", () => { test( "update-root-key with piped key but no resolvable ref exits non-zero", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["encryption", "update-root-key"], { + () => + runSupabase(["encryption", "update-root-key"], { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, stdin: "newkey\n", - }); - expect(exitCode).not.toBe(0); - expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); + }), ); }); diff --git a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts index 5e6d17f393..d40c59af8f 100644 --- a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Formatter, Option } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -92,7 +92,7 @@ describe("legacy encryption get-root-key integration", () => { const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyEncryptionNetworkError"); expect(json).toContain("failed to retrieve pgsodium config"); } @@ -105,7 +105,7 @@ describe("legacy encryption get-root-key integration", () => { const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyEncryptionUnexpectedStatusError"); expect(json).toContain("unexpected get pgsodium config status 503"); } @@ -127,7 +127,7 @@ describe("legacy encryption get-root-key integration", () => { const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts index 07b0a273d9..380bb5da79 100644 --- a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Formatter, Layer, Option } from "effect"; import { mockOutput, mockStdin } from "../../../../../tests/helpers/mocks.ts"; import { @@ -127,7 +127,7 @@ describe("legacy encryption update-root-key integration", () => { const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyEncryptionNetworkError"); expect(json).toContain("failed to update pgsodium config"); } @@ -140,7 +140,7 @@ describe("legacy encryption update-root-key integration", () => { const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyEncryptionUnexpectedStatusError"); expect(json).toContain("unexpected update pgsodium config status 503"); } @@ -162,7 +162,7 @@ describe("legacy encryption update-root-key integration", () => { const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts index e40b1d228a..ca2da9dd98 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.integration.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option, Stdio } from "effect"; -import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { @@ -16,6 +17,9 @@ import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { legacyFunctionsDeleteHandler } from "./delete.command.ts"; import { legacyFunctionsDelete } from "./delete.handler.ts"; +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + const tempRoot = useLegacyTempWorkdir("supabase-functions-delete-legacy-"); // `withLegacyCommandInstrumentation` threads `flags`/`command`/etc. through @@ -43,8 +47,8 @@ function mockContextualAnalytics() { // Strip ANSI SGR (aqua slug/ref via `legacyAqua`) so byte-assertions are // stable whether or not the test stdout supports color. -// eslint-disable-next-line no-control-regex -const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +const stripSgr = (text: string) => + text.replace(new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "gu"), ""); describe("legacy functions delete", () => { it.live("deletes a function natively through the Management API", () => { diff --git a/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts b/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts index 9b4b06f88c..3bb77b7fb3 100644 --- a/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts +++ b/apps/cli/src/legacy/commands/functions/delete/delete.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and creates unique remote resources. import { randomUUID } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts index a26a467e53..0048443767 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- legacy e2e exercises the subprocess and temporary filesystem boundary directly. import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts index cb63cd5df4..728b1b8aef 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.handler.ts @@ -1,5 +1,4 @@ -import { join } from "node:path"; -import { Effect, Option, Stdio } from "effect"; +import { Effect, Option, Path, Stdio } from "effect"; import { deployFunctions } from "../../../../shared/functions/deploy.ts"; import { resolveEdgeRuntimeVersionPin } from "../../../../shared/functions/functions.shared.ts"; import { legacyAqua, legacyBold, legacyYellow } from "../../../shared/legacy-colors.ts"; @@ -26,10 +25,12 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const runtimeInfo = yield* RuntimeInfo; + const goConfigCompat = yield* legacyFunctionsGoConfigCompat; + const path = yield* Path.Path; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin( - join(cliConfig.workdir, "supabase"), + path.join(cliConfig.workdir, "supabase"), ); let resolvedProjectRef = Option.none<string>(); @@ -38,9 +39,9 @@ export const legacyFunctionsDeploy = Effect.fn("legacy.functions.deploy")(functi cwd: cliConfig.workdir, flagCwd: runtimeInfo.cwd, projectRoot: cliConfig.workdir, - supabaseDir: join(cliConfig.workdir, "supabase"), + supabaseDir: path.join(cliConfig.workdir, "supabase"), dashboardUrl: legacyDashboardUrl(cliConfig.profile), - goConfigCompat: legacyFunctionsGoConfigCompat, + goConfigCompat, yes, rawArgs, edgeRuntimeVersion, diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts index 0880e575f2..499a8097c9 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.integration.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { Effect, Exit, Layer, Option, Stdio } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { @@ -46,26 +44,57 @@ const baseFlags: LegacyFunctionsDeployFlags = { legacyBundle: false, }; -async function writeProjectConfig(cwd: string, content = 'project_id = "test-project"\n') { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), content); +const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const join = (...parts: ReadonlyArray<string>) => pathService.join(...parts); +const dirname = (path: string) => pathService.dirname(path); +const withBunServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => + effect.pipe(Effect.provide(BunServices.layer)); +const makeDirectory = (path: string) => + withBunServices( + Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).makeDirectory(path, { recursive: true }); + }), + ); +const writeText = (path: string, contents: string) => + withBunServices( + Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).writeFileString(path, contents); + }), + ); +const removeTree = (path: string) => + withBunServices( + Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).remove(path, { recursive: true, force: true }); + }), + ).pipe(Effect.orDie); +const encodeImportMap = Schema.encodeSync( + Schema.fromJsonString(Schema.Struct({ imports: Schema.Record(Schema.String, Schema.String) })), +); + +function writeProjectConfig(cwd: string, content = 'project_id = "test-project"\n') { + return Effect.gen(function* () { + yield* makeDirectory(join(cwd, "supabase")); + yield* writeText(join(cwd, "supabase", "config.toml"), content); + }); } -async function writeLocalFunction( +function writeLocalFunction( cwd: string, slug: string, source = "Deno.serve(() => new Response())\n", ) { const functionDir = join(cwd, "supabase", "functions", slug); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), source); - await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); + return Effect.gen(function* () { + yield* makeDirectory(functionDir); + yield* writeText(join(functionDir, "index.ts"), source); + yield* writeText(join(functionDir, "deno.json"), '{"imports":{}}\n'); + }); } // Strip ANSI SGR (color/bold) sequences — `legacyBold` styles the pruned slugs // only when stderr supports color, so byte-assertions normalize first. -// eslint-disable-next-line no-control-regex -const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); +const stripSgr = (text: string) => + text.replace(new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "gu"), ""); function resolveDockerOutputPath(args: ReadonlyArray<string>): string { const outputIndex = args.indexOf("--output"); @@ -87,15 +116,19 @@ function mockDockerBundleSpawner() { exitCode?: number; onSpawn?: (record: { command: string; args: ReadonlyArray<string> }) => void; } = { exitCode: 0 }; - spawnerOpts.onSpawn = (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }; - return mockChildProcessSpawner(spawnerOpts); + return mockChildProcessSpawner({ + ...spawnerOpts, + beforeSpawn: (record) => { + if (record.command !== "docker" || record.args[0] !== "run") { + return Effect.void; + } + const outputPath = resolveDockerOutputPath(record.args); + return Effect.gen(function* () { + yield* makeDirectory(dirname(outputPath)); + yield* writeText(outputPath, "eszip-test-output"); + }).pipe(Effect.orDie); + }, + }); } describe("legacy functions deploy", () => { @@ -144,8 +177,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy(baseFlags); @@ -162,12 +195,7 @@ describe("legacy functions deploy", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("prints a duplicated slug argument verbatim, matching Go's raw strings.Join", () => { @@ -214,8 +242,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -230,12 +258,7 @@ describe("legacy functions deploy", () => { expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world, hello-world\n", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("uses an explicit project ref when provided", () => { @@ -286,8 +309,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -298,12 +321,7 @@ describe("legacy functions deploy", () => { (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), ); expect(deployRequest?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/deploy"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("resolves --import-map relative to the caller cwd", () => { @@ -352,12 +370,10 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); - yield* Effect.tryPromise(() => mkdir(callerDir, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(callerDir, "import_map.json"), '{"imports":{}}'), - ); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* makeDirectory(callerDir); + yield* writeText(join(callerDir, "import_map.json"), '{"imports":{}}'); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -368,12 +384,7 @@ describe("legacy functions deploy", () => { expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("loads project config from the resolved workdir", () => { @@ -411,16 +422,14 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeProjectConfig( - tempRoot.current, - ['project_id = "test-project"', "[functions.configured]", "verify_jwt = false", ""].join( - "\n", - ), + yield* writeProjectConfig( + tempRoot.current, + ['project_id = "test-project"', "[functions.configured]", "verify_jwt = false", ""].join( + "\n", ), ); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "configured")); - yield* Effect.tryPromise(() => mkdir(callerDir, { recursive: true })); + yield* writeLocalFunction(tempRoot.current, "configured"); + yield* makeDirectory(callerDir); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -429,12 +438,7 @@ describe("legacy functions deploy", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.urlParams).toContain("slug=configured"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("rejects a bundled file whose workdir-relative name escapes with a `..` segment", () => { @@ -497,31 +501,23 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(repoRoot, ".git"), { recursive: true })); - yield* Effect.tryPromise(() => writeProjectConfig(workdir)); - yield* Effect.tryPromise(() => - writeLocalFunction( - workdir, - "hello-world", - 'import { shared } from "@repo/shared"\nDeno.serve(() => new Response(shared))\n', - ), - ); - yield* Effect.tryPromise(() => - mkdir(join(repoRoot, "packages", "shared", "src"), { recursive: true }), + yield* makeDirectory(join(repoRoot, ".git")); + yield* writeProjectConfig(workdir); + yield* writeLocalFunction( + workdir, + "hello-world", + 'import { shared } from "@repo/shared"\nDeno.serve(() => new Response(shared))\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(repoRoot, "packages", "shared", "src", "index.ts"), - 'export const shared = "ok"\n', - ), + yield* makeDirectory(join(repoRoot, "packages", "shared", "src")); + yield* writeText( + join(repoRoot, "packages", "shared", "src", "index.ts"), + 'export const shared = "ok"\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(workdir, "supabase", "functions", "hello-world", "deno.json"), - JSON.stringify({ - imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, - }), - ), + yield* writeText( + join(workdir, "supabase", "functions", "hello-world", "deno.json"), + encodeImportMap({ + imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, + }), ); const exit = yield* Effect.exit(legacyFunctionsDeploy(baseFlags)); @@ -533,12 +529,7 @@ describe("legacy functions deploy", () => { ); } expect(multiparts).toHaveLength(0); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("deploys config-declared custom entrypoints when deploying all functions", () => { @@ -579,33 +570,23 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeProjectConfig( - tempRoot.current, - [ - 'project_id = "test-project"', - '[functions."custom-entry"]', - 'entrypoint = "./functions/custom-entry/handler.ts"', - "", - ].join("\n"), - ), + yield* writeProjectConfig( + tempRoot.current, + [ + 'project_id = "test-project"', + '[functions."custom-entry"]', + 'entrypoint = "./functions/custom-entry/handler.ts"', + "", + ].join("\n"), ); - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase", "functions", "custom-entry"), { - recursive: true, - }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "functions", "custom-entry", "handler.ts"), - 'Deno.serve(() => new Response("custom"))\n', - ), + yield* makeDirectory(join(tempRoot.current, "supabase", "functions", "custom-entry")); + yield* writeText( + join(tempRoot.current, "supabase", "functions", "custom-entry", "handler.ts"), + 'Deno.serve(() => new Response("custom"))\n', ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "functions", "custom-entry", "deno.json"), - '{"imports":{}}\n', - ), + yield* writeText( + join(tempRoot.current, "supabase", "functions", "custom-entry", "deno.json"), + '{"imports":{}}\n', ); yield* legacyFunctionsDeploy({ @@ -621,12 +602,7 @@ describe("legacy functions deploy", () => { expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: custom-entry\n", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("honors global --yes when pruning remote functions", () => { @@ -691,8 +667,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, prune: true }); @@ -704,12 +680,7 @@ describe("legacy functions deploy", () => { "Do you want to delete the following Functions from your project?\n • remote-only\n\n [y/N] y\n", ); expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); // INC-699: a `bundleOnly` upload bumps the remote version without persisting @@ -787,9 +758,9 @@ describe("legacy functions deploy", () => { const { out, api, layer } = setupBulkDeploy({ deployStatuses: [201, 409] }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "bye-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); const error = yield* legacyFunctionsDeploy({ ...baseFlags, @@ -810,21 +781,16 @@ describe("legacy functions deploy", () => { const bulkUpdate = api.requests.find((request) => request.method === "PUT"); expect(bulkUpdate?.body).toMatchObject([{ slug: "hello-world" }]); expect(out.stdoutText).not.toContain("Deployed Functions on project"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("skips the bulk update entirely when every upload fails", () => { const { out, api, layer } = setupBulkDeploy({ deployStatuses: [409, 400] }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "bye-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); const error = yield* legacyFunctionsDeploy({ ...baseFlags, @@ -840,12 +806,7 @@ describe("legacy functions deploy", () => { ); expect(api.requests.some((request) => request.method === "PUT")).toBe(false); expect(out.stdoutText).not.toContain("Deployed Functions on project"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("reports the upload failure and the bulk update failure together", () => { @@ -855,9 +816,9 @@ describe("legacy functions deploy", () => { }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "bye-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); const error = yield* legacyFunctionsDeploy({ ...baseFlags, @@ -872,12 +833,7 @@ describe("legacy functions deploy", () => { ].join("\n"), ); expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); }); @@ -1012,8 +968,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -1024,12 +980,7 @@ describe("legacy functions deploy", () => { expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("treats --jobs 0 as 1 and does not require --use-api", () => { @@ -1070,8 +1021,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -1082,12 +1033,7 @@ describe("legacy functions deploy", () => { expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); }); @@ -1137,8 +1083,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, @@ -1153,12 +1099,7 @@ describe("legacy functions deploy", () => { expect(stripSgr(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); }); @@ -1192,6 +1133,7 @@ describe("legacy functions deploy", () => { ); const deployNoFunctions = Effect.gen(function* () { const platformApi = yield* LegacyPlatformApi; + const goConfigCompat = yield* legacyFunctionsGoConfigCompat; return yield* deployFunctions( { ...baseFlags, functionNames: [] }, { @@ -1201,7 +1143,7 @@ describe("legacy functions deploy", () => { projectRoot: tempRoot.current, supabaseDir: join(tempRoot.current, "supabase"), dashboardUrl: "https://supabase.com/dashboard", - goConfigCompat: legacyFunctionsGoConfigCompat, + goConfigCompat, yes: false, rawArgs: ["functions", "deploy"], edgeRuntimeVersion: "1.69.12", @@ -1216,7 +1158,7 @@ describe("legacy functions deploy", () => { it.live("keeps the injected styling out of the json error payload", () => { const { out, layer, deployNoFunctions } = setupNoFunctionsTest("json"); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* writeProjectConfig(tempRoot.current); yield* deployNoFunctions.pipe(withJsonErrorHandling); @@ -1224,18 +1166,13 @@ describe("legacy functions deploy", () => { type: "fail", message: "No Functions specified or found in supabase/functions", }); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("still emphasizes the functions dir in the text-mode error", () => { const { layer, deployNoFunctions } = setupNoFunctionsTest("text"); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* writeProjectConfig(tempRoot.current); const error = yield* deployNoFunctions.pipe(Effect.flip); @@ -1246,12 +1183,7 @@ describe("legacy functions deploy", () => { expect(error.message).toBe( "No Functions specified or found in <sgr>supabase/functions</sgr>", ); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); }); @@ -1275,20 +1207,15 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current, 'project_id = ""\n')); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current, 'project_id = ""\n'); + yield* writeLocalFunction(tempRoot.current, "hello-world"); const error = yield* legacyFunctionsDeploy(baseFlags).pipe(Effect.flip); expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("Missing required field in config: project_id"); expect(api.requests).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); @@ -1314,13 +1241,11 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeProjectConfig( - tempRoot.current, - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), + yield* writeProjectConfig( + tempRoot.current, + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), ); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeLocalFunction(tempRoot.current, "hello-world"); const error = yield* legacyFunctionsDeploy(baseFlags).pipe(Effect.flip); @@ -1329,12 +1254,7 @@ describe("legacy functions deploy", () => { "Postgres version 12.x is unsupported. To use the CLI, either start a new project or follow project migration steps here: https://supabase.com/docs/guides/database#migrating-between-projects.", ); expect(api.requests).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); @@ -1357,7 +1277,7 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current, 'project_id = ""\n')); + yield* writeProjectConfig(tempRoot.current, 'project_id = ""\n'); const error = yield* legacyFunctionsDeploy({ ...baseFlags, @@ -1367,12 +1287,7 @@ describe("legacy functions deploy", () => { expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe("Missing required field in config: project_id"); expect(api.requests).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); @@ -1393,7 +1308,7 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); + yield* writeProjectConfig(tempRoot.current); const error = yield* legacyFunctionsDeploy({ ...baseFlags, @@ -1402,12 +1317,7 @@ describe("legacy functions deploy", () => { expect(error).toBeInstanceOf(InvalidFunctionDeploySlugError); expect(api.requests).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); }); @@ -1448,6 +1358,7 @@ describe("legacy functions deploy", () => { api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + env: { SUPABASE_EDGE_RUNTIME_DENO_VERSION: "1" }, }), Layer.succeed(LegacyYesFlag, false), child.layer, @@ -1456,12 +1367,9 @@ describe("legacy functions deploy", () => { }), ); - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); @@ -1471,21 +1379,7 @@ describe("legacy functions deploy", () => { command: "docker", args: ["image", "inspect", "public.ecr.aws/supabase/edge-runtime:v1.68.4"], }); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); @@ -1501,6 +1395,7 @@ describe("legacy functions deploy", () => { api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + env: { SUPABASE_NETWORK_ID: "env-network" }, }), Layer.succeed(LegacyYesFlag, false), child.layer, @@ -1509,12 +1404,9 @@ describe("legacy functions deploy", () => { }), ); - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); @@ -1524,24 +1416,46 @@ describe("legacy functions deploy", () => { }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).toContain("env-network"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); + it.live("uses project dotenv Bitbucket settings for Docker volume creation and mounts", () => { + const out = mockOutput({ format: "text" }); + const api = mockFunctionCreateApi(); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(LegacyYesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api=false"]), + }), + ); + + return Effect.gen(function* () { + yield* writeProjectConfig(tempRoot.current); + yield* writeText( + join(tempRoot.current, "supabase", ".env"), + "BITBUCKET_CLONE_DIR=/opt/bitbucket\n", + ); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); + + expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(runCommand?.args).not.toContain( + "supabase_edge_runtime_test-project:/root/.cache/deno:rw", + ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); + }); + it.live( "prefers an explicit --network-id flag over SUPABASE_NETWORK_ID for the bundler container", () => { @@ -1554,6 +1468,7 @@ describe("legacy functions deploy", () => { api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + env: { SUPABASE_NETWORK_ID: "env-network" }, }), Layer.succeed(LegacyYesFlag, false), child.layer, @@ -1569,12 +1484,9 @@ describe("legacy functions deploy", () => { }), ); - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); @@ -1585,21 +1497,7 @@ describe("legacy functions deploy", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).toContain("flag-network"); expect(runCommand?.args).not.toContain("env-network"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); @@ -1624,10 +1522,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeProjectConfig(tempRoot.current, 'project_id = "test-project"\n'), - ); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current, 'project_id = "test-project"\n'); + yield* writeLocalFunction(tempRoot.current, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); @@ -1658,12 +1554,7 @@ describe("legacy functions deploy", () => { "-w", toDockerPath(tempRoot.current), ]); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); @@ -1694,10 +1585,8 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeProjectConfig(tempRoot.current, 'project_id = "ancestor-project"\n'), - ); - yield* Effect.tryPromise(() => writeLocalFunction(nestedWorkdir, "hello-world")); + yield* writeProjectConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); + yield* writeLocalFunction(nestedWorkdir, "hello-world"); yield* legacyFunctionsDeploy({ ...baseFlags, useApi: false, useDocker: true }); @@ -1708,12 +1597,7 @@ describe("legacy functions deploy", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).toContain("supabase_network_abcdefghijklmnopqrst"); expect(runCommand?.args).not.toContain("supabase_network_ancestor-project"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }, ); }); @@ -1763,10 +1647,11 @@ describe("legacy functions deploy", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempRoot.current)); - yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); + yield* writeProjectConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); const platformApi = yield* LegacyPlatformApi; + const goConfigCompat = yield* legacyFunctionsGoConfigCompat; yield* deployFunctions( { ...baseFlags, useApi: false, useDocker: true }, { @@ -1776,7 +1661,7 @@ describe("legacy functions deploy", () => { projectRoot: tempRoot.current, supabaseDir: join(tempRoot.current, "supabase"), dashboardUrl: "https://supabase.com/dashboard", - goConfigCompat: legacyFunctionsGoConfigCompat, + goConfigCompat, yes: false, rawArgs: ["functions", "deploy", "hello-world", "--use-api=false"], edgeRuntimeVersion: "1.69.12", @@ -1786,12 +1671,7 @@ describe("legacy functions deploy", () => { ); expect(out.stderrText).toContain("<warn>WARNING:</warn> Docker is not running\n"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.tryPromise(() => rm(tempRoot.current, { recursive: true, force: true })), - ), - ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); }); }); diff --git a/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts b/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts index 76e83ced69..114f0003fe 100644 --- a/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts +++ b/apps/cli/src/legacy/commands/functions/deploy/deploy.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and creates unique remote resources. import { randomUUID } from "node:crypto"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts b/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts index 9c3b2af772..3fcd895243 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- legacy e2e test callbacks are Promise-based at the subprocess boundary. import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; diff --git a/apps/cli/src/legacy/commands/functions/download/download.handler.ts b/apps/cli/src/legacy/commands/functions/download/download.handler.ts index 2782fa531e..d995849b49 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.handler.ts @@ -1,5 +1,4 @@ -import { join } from "node:path"; -import { Effect, Option, Stdio } from "effect"; +import { Effect, Option, Path, Stdio } from "effect"; import { downloadFunctions, makeGoProxyLegacyBundleArgs, @@ -24,10 +23,12 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const proxy = yield* LegacyGoProxy; + const goConfigCompat = yield* legacyFunctionsGoConfigCompat; + const path = yield* Path.Path; const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersionPin( - join(cliConfig.workdir, "supabase"), + path.join(cliConfig.workdir, "supabase"), ); let resolvedProjectRef = Option.none<string>(); @@ -35,7 +36,7 @@ export const legacyFunctionsDownload = Effect.fn("legacy.functions.download")(fu api, projectRoot: cliConfig.workdir, rawArgs, - goConfigCompat: legacyFunctionsGoConfigCompat, + goConfigCompat, edgeRuntimeVersion, // Established styling: bold on the `Downloading function:` slug // (stderr) — matches `legacyBold`'s default TTY gate. diff --git a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts index 382a6f967a..0970b8e125 100644 --- a/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/download/download.integration.test.ts @@ -1,13 +1,25 @@ import { describe, expect, it } from "@effect/vitest"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; -import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { + Deferred, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Schema, + Sink, + Stdio, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "../../../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../../../shared/telemetry/analytics.service.ts"; import { @@ -31,7 +43,57 @@ import { legacyFunctionsDownloadHandler } from "./download.command.ts"; import type { LegacyFunctionsDownloadFlags } from "./download.command.ts"; import { legacyFunctionsDownload } from "./download.handler.ts"; +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + const PROJECT_ID = "abcdefghijklmnopqrst"; +const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const join = (...parts: ReadonlyArray<string>) => pathService.join(...parts); +const resolve = (...parts: ReadonlyArray<string>) => pathService.resolve(...parts); +const encodeMultipartMetadata = Schema.encodeSync( + Schema.fromJsonString(Schema.Struct({ deno2_entrypoint_path: Schema.String })), +); +const encodeProjectConfigJson = Schema.encodeSync( + Schema.fromJsonString(Schema.Struct({ project_id: Schema.String })), +); +const withBunServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => + effect.pipe(Effect.provide(BunServices.layer)); +const configEnvLayer = (env: Readonly<Record<string, string>>) => + ConfigProvider.layer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })); +const makeDirectory = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }), + ); +const writeText = (path: string, contents: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }), + ); +const readText = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }), + ); +const fileExists = (path: string) => + withBunServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }), + ); +const removeTree = (path: string) => + withBunServices( + Effect.gen(function* () { + yield* (yield* FileSystem.FileSystem).remove(path, { recursive: true, force: true }); + }), + ).pipe(Effect.orDie); /** * Mutates the shared spawner options object from inside `onSpawn`, scoped to @@ -87,14 +149,12 @@ function mockDockerRunSpawnFailure() { spawned.push({ command: cmd, args }); if (args[0] === "run") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: `${cmd} not found`, - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${cmd} not found`, + }); } const exitDeferred = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); @@ -163,7 +223,7 @@ function multipartResponse(request: Parameters<typeof HttpClientResponse.fromWeb 'Content-Disposition: form-data; name="metadata"', "Content-Type: application/json", "", - JSON.stringify({ deno2_entrypoint_path: "source/index.ts" }), + encodeMultipartMetadata({ deno2_entrypoint_path: "source/index.ts" }), `--${boundary}`, 'Content-Disposition: form-data; name="file"; filename="source/index.ts"', "", @@ -243,12 +303,7 @@ describe("legacy functions download", () => { expect(proxy.calls).toEqual([]); expect( - yield* Effect.tryPromise(() => - readFile( - join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), - "utf8", - ), - ), + yield* readText(join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts")), ).toBe("console.log('legacy native')"); expect(out.stderrText).toContain( "Downloaded Function hello-world from project abcdefghijklmnopqrst.", @@ -317,7 +372,9 @@ describe("legacy functions download", () => { expect(out.stderrText).not.toContain("Downloaded Function"); // No `--debug` — the temp eszip file is removed after the run. expect( - existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + yield* fileExists( + join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip"), + ), ).toBe(false); }).pipe(Effect.provide(layer)); }, @@ -362,11 +419,8 @@ describe("legacy functions download", () => { expect(proxy.calls).toEqual([]); expect( - yield* Effect.tryPromise(() => - readFile( - join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), - "utf8", - ), + yield* readText( + join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), ), ).toBe("console.log('legacy native')"); }).pipe(Effect.provide(layer)); @@ -642,7 +696,9 @@ describe("legacy functions download", () => { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + env: { BITBUCKET_CLONE_DIR: "/opt/atlassian/pipelines/agent/build" }, }), + configEnvLayer({ BITBUCKET_CLONE_DIR: "/opt/atlassian/pipelines/agent/build" }), proxy.layer, child.layer, Stdio.layerTest({ @@ -657,12 +713,8 @@ describe("legacy functions download", () => { }), ); - const previousBitbucketCloneDir = process.env["BITBUCKET_CLONE_DIR"]; - process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; - return Effect.gen(function* () { yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).not.toContain( `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, @@ -676,19 +728,52 @@ describe("legacy functions download", () => { expect(runCommand?.args).toContain( `${hostEszipPath}:/root/eszips/output_hello-world.eszip:ro`, ); - }) - .pipe(Effect.provide(layer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousBitbucketCloneDir === undefined) { - delete process.env["BITBUCKET_CLONE_DIR"]; - } else { - process.env["BITBUCKET_CLONE_DIR"] = previousBitbucketCloneDir; - } - }), - ), + }).pipe(Effect.provide(layer)); + }); + + it.live("uses project dotenv Bitbucket settings for Docker volume creation and mounts", () => { + const out = mockOutput({ format: "text" }); + const api = mockLegacyPlatformApi(); + const proxy = mockProxy(); + const child = mockChildProcessSpawner({ exitCode: 0 }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + }), + proxy.layer, + child.layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-docker", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", ".env"), + "BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n", + ); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + 'project_id = "' + PROJECT_ID + '"\n', + ); + + yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); + const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); + expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); + expect(runCommand?.args).not.toContain( + `supabase_edge_runtime_${PROJECT_ID}:/root/.cache/deno:rw`, ); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTree(tempRoot.current))); }); it.live("requests the raw eszip body instead of a negotiated JSON response", () => { @@ -942,15 +1027,11 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.toml"), - ['project_id = "ancestor-project"', ""].join("\n"), - ), + yield* makeDirectory(nestedWorkdir); + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "ancestor-project"', ""].join("\n"), ); yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); @@ -998,20 +1079,14 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "toml-project"', ""].join("\n"), ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.toml"), - ['project_id = "toml-project"', ""].join("\n"), - ), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.json"), - JSON.stringify({ project_id: "json-project" }), - ), + yield* writeText( + join(tempRoot.current, "supabase", "config.json"), + encodeProjectConfigJson({ project_id: "json-project" }), ); yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); @@ -1096,11 +1171,10 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase", ".temp"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"), + yield* makeDirectory(join(tempRoot.current, "supabase", ".temp")); + yield* writeText( + join(tempRoot.current, "supabase", ".temp", "edge-runtime-version"), + "v9.9.9\n", ); yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); @@ -1140,7 +1214,7 @@ describe("legacy functions download", () => { yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); expect( - existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + yield* fileExists(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), ).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -1181,7 +1255,9 @@ describe("legacy functions download", () => { yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); expect( - existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + yield* fileExists( + join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip"), + ), ).toBe(false); }).pipe(Effect.provide(layer)); }, @@ -1221,14 +1297,10 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.toml"), - ["[edge_runtime]", "deno_version = 3", ""].join("\n"), - ), + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 3", ""].join("\n"), ); const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( @@ -1318,14 +1390,10 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.toml"), - ["[edge_runtime]", "deno_version = 1", ""].join("\n"), - ), + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 1", ""].join("\n"), ); const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( @@ -1370,14 +1438,10 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.toml"), - ["[edge_runtime]", "deno_version = 1", ""].join("\n"), - ), + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + ["[edge_runtime]", "deno_version = 1", ""].join("\n"), ); const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( @@ -1442,7 +1506,7 @@ describe("legacy functions download", () => { // is still cleaned up even though the failure happened before Docker // ever ran — not only after a successful `runChildProcess` call. expect( - existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + yield* fileExists(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), ).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -1493,7 +1557,9 @@ describe("legacy functions download", () => { ); expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(true); expect( - existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), + yield* fileExists( + join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip"), + ), ).toBe(false); }).pipe(Effect.provide(layer)); }, @@ -1844,12 +1910,8 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "config.toml"), 'project_id = ""\n'), - ); + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText(join(tempRoot.current, "supabase", "config.toml"), 'project_id = ""\n'); const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( Effect.flip, @@ -1893,14 +1955,10 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", "config.toml"), - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), ); const error = yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }).pipe( @@ -1929,7 +1987,9 @@ describe("legacy functions download", () => { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + env: { SUPABASE_EDGE_RUNTIME_DENO_VERSION: "1" }, }), + configEnvLayer({ SUPABASE_EDGE_RUNTIME_DENO_VERSION: "1" }), proxy.layer, child.layer, Stdio.layerTest({ @@ -1944,29 +2004,13 @@ describe("legacy functions download", () => { }), ); - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; - return Effect.gen(function* () { yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args.slice(-6)[0]).toBe( "public.ecr.aws/supabase/edge-runtime:v1.68.4", ); - }) - .pipe(Effect.provide(layer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); @@ -1982,7 +2026,9 @@ describe("legacy functions download", () => { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + env: { SUPABASE_NETWORK_ID: "env-network" }, }), + configEnvLayer({ SUPABASE_NETWORK_ID: "env-network" }), proxy.layer, child.layer, Stdio.layerTest({ @@ -1997,31 +2043,15 @@ describe("legacy functions download", () => { }), ); - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - return Effect.gen(function* () { yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ command: "docker", args: ["network", "inspect", "env-network"], }); const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).toContain("env-network"); - }) - .pipe(Effect.provide(layer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); @@ -2035,7 +2065,9 @@ describe("legacy functions download", () => { out, api, cliConfig: mockLegacyCliConfig({ workdir: tempRoot.current }), + env: { SUPABASE_NETWORK_ID: "env-network" }, }), + configEnvLayer({ SUPABASE_NETWORK_ID: "env-network" }), proxy.layer, child.layer, Stdio.layerTest({ @@ -2052,12 +2084,8 @@ describe("legacy functions download", () => { }), ); - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - return Effect.gen(function* () { yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); - expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ command: "docker", args: ["network", "inspect", "flag-network"], @@ -2065,19 +2093,7 @@ describe("legacy functions download", () => { const runCommand = child.spawned.find((spawned) => spawned.args[0] === "run"); expect(runCommand?.args).toContain("flag-network"); expect(runCommand?.args).not.toContain("env-network"); - }) - .pipe(Effect.provide(layer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer)); }); it.live( @@ -2108,14 +2124,10 @@ describe("legacy functions download", () => { ); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile( - join(tempRoot.current, "supabase", ".env"), - "SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io\n", - ), + yield* makeDirectory(join(tempRoot.current, "supabase")); + yield* writeText( + join(tempRoot.current, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io\n", ); yield* legacyFunctionsDownload({ ...baseFlags, useDocker: true }); @@ -2129,9 +2141,6 @@ describe("legacy functions download", () => { (spawned) => spawned.args[0] === "image" && spawned.args[1] === "inspect", ), ).toHaveLength(1); - // Proves the registry came from the project dotenv file, not the - // ambient shell environment. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); }).pipe(Effect.provide(layer)); }, ); @@ -2279,6 +2288,7 @@ describe("legacy functions download", () => { return Effect.gen(function* () { const platformApi = yield* LegacyPlatformApi; + const goConfigCompat = yield* legacyFunctionsGoConfigCompat; yield* downloadFunctions( { ...baseFlags, useDocker: true }, @@ -2286,7 +2296,7 @@ describe("legacy functions download", () => { api: platformApi, projectRoot: tempRoot.current, rawArgs: ["functions", "download", "hello-world", "--project-ref", PROJECT_ID], - goConfigCompat: legacyFunctionsGoConfigCompat, + goConfigCompat, edgeRuntimeVersion: "1.69.12", resolveProjectRef: () => Effect.succeed(PROJECT_ID), proxyDownload: () => Effect.die("unexpected proxy invocation"), diff --git a/apps/cli/src/legacy/commands/functions/list/list.format.ts b/apps/cli/src/legacy/commands/functions/list/list.format.ts index bc7d00c82a..393ce612cd 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.format.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.format.ts @@ -1,18 +1,12 @@ import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { DateTime } from "effect"; import type { Functions } from "./list.encoders.ts"; export function formatUnixMilliTimestamp(value: number): string { - const date = new Date(value); - const parts = [ - date.getUTCFullYear(), - date.getUTCMonth() + 1, - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds(), - ]; - const [year, ...rest] = parts.map((part) => part.toString().padStart(2, "0")); - return `${year}-${rest[0]}-${rest[1]} ${rest[2]}:${rest[3]}:${rest[4]}`; + const iso = DateTime.formatIso(DateTime.makeUnsafe(value)); + const date = iso.slice(0, 10); + const time = iso.slice(11, 19); + return `${date} ${time}`; } export function renderFunctionsTable(functions: Functions): string { diff --git a/apps/cli/src/legacy/commands/functions/list/list.handler.ts b/apps/cli/src/legacy/commands/functions/list/list.handler.ts index e4a669f7e8..aecd5974b3 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.handler.ts @@ -6,6 +6,7 @@ import { Output } from "../../../../shared/output/output.service.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { mapLegacyHttpError, sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { legacyErrorMessage } from "../../../shared/legacy-error-message.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { @@ -26,7 +27,7 @@ import type { LegacyFunctionsListFlags } from "./list.command.ts"; const mapListError = mapLegacyHttpError({ networkError: LegacyFunctionsListNetworkError, statusError: LegacyFunctionsListUnexpectedStatusError, - networkMessage: (cause) => `failed to list functions: ${cause}`, + networkMessage: (cause) => `failed to list functions: ${legacyErrorMessage(cause)}`, statusMessage: (status, body) => `unexpected list functions status ${status}: ${body}`, }); @@ -71,7 +72,9 @@ export const legacyFunctionsList = Effect.fn("legacy.functions.list")(function* Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.catch( (cause) => - new LegacyFunctionsListNetworkError({ message: `failed to list functions: ${cause}` }), + new LegacyFunctionsListNetworkError({ + message: `failed to list functions: ${legacyErrorMessage(cause)}`, + }), ), ); if (!hasJsonContentType(response)) { diff --git a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts index e7171e018c..ab0d6b72e0 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.integration.test.ts @@ -1,6 +1,6 @@ import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Formatter, Layer, Option } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { @@ -204,7 +204,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsEnvNotSupportedError"); expect(json).toContain("--output env flag is not supported"); } @@ -270,7 +270,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsListUnexpectedStatusError"); expect(json).toContain("unexpected list functions status 503"); } @@ -283,7 +283,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsListNetworkError"); expect(json).toContain("failed to list functions"); } @@ -310,7 +310,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsListNetworkError"); expect(json).toContain("failed to list functions:"); } @@ -337,7 +337,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsListUnexpectedStatusError"); expect(json).toContain("unexpected list functions status 200"); expect(json).toContain("Hello World"); @@ -351,7 +351,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsListNetworkError"); expect(json).toContain("failed to list functions"); } @@ -364,7 +364,7 @@ describe("legacy functions list integration", () => { const exit = yield* Effect.exit(legacyFunctionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyFunctionsListNetworkError"); expect(json).toContain("failed to list functions"); } diff --git a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts index 801dbd4906..42ebeefede 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { describe, expect } from "vitest"; import { test } from "../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index 07340673c5..323c073ff9 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -179,12 +179,10 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( yield* Effect.gen(function* () { const invalidSlugMessage = validateFunctionSlugMessage(flags.functionName); if (invalidSlugMessage !== undefined) { - return yield* Effect.fail( - new LegacyFunctionsNewInvalidSlugError({ - message: invalidSlugMessage, - detail: invalidFunctionSlugDetail, - }), - ); + return yield* new LegacyFunctionsNewInvalidSlugError({ + message: invalidSlugMessage, + detail: invalidFunctionSlugDetail, + }); } const existingSlugs = yield* listExistingFunctionSlugs(cliConfig.workdir); @@ -210,13 +208,11 @@ export const legacyFunctionsNew = Effect.fn("legacy.functions.new")(function* ( .exists(entrypointPath) .pipe(Effect.orElseSucceed(() => false)); if (entrypointExists) { - return yield* Effect.fail( - new LegacyFunctionsNewFileExistsError({ - path: relEntrypoint, - message: "failed to create entrypoint: file already exists", - suggestion: `Remove ${relEntrypoint} or use a different Function name.`, - }), - ); + return yield* new LegacyFunctionsNewFileExistsError({ + path: relEntrypoint, + message: "failed to create entrypoint: file already exists", + suggestion: `Remove ${relEntrypoint} or use a different Function name.`, + }); } const templateInputs = yield* resolveTemplateInputs(cliConfig.workdir, flags.functionName); diff --git a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts index a891fb6d93..08de517b03 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.integration.test.ts @@ -1,10 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; - -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockLegacyCliConfig, @@ -14,10 +10,32 @@ import { import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyFunctionsNew } from "./new.handler.ts"; import { LEGACY_FUNCTIONS_NEW_DENO_JSON, LEGACY_FUNCTIONS_NEW_NPMRC } from "./new.templates.ts"; const tempRoot = useLegacyTempWorkdir("supabase-functions-new-int-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + +const readText = (file: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).readFileString(file); + }); + +const writeText = (file: string, content: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).writeFileString(file, content); + }); + +const makeDirectory = (directory: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).makeDirectory(directory, { recursive: true }); + }); + +const exists = (file: string) => + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).exists(file); + }); interface SetupOptions { readonly format?: "text" | "json" | "stream-json"; @@ -27,9 +45,14 @@ interface SetupOptions { readonly promptConfirmResponses?: ReadonlyArray<boolean>; /** Piped stdin lines consumed by the non-TTY IDE-settings confirm reads. */ readonly stdinInput?: string; + readonly env?: Readonly<Record<string, string>>; } function setup(options: SetupOptions = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: options.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: options.format ?? "text", promptConfirmResponses: options.promptConfirmResponses, @@ -38,6 +61,8 @@ function setup(options: SetupOptions = {}) { const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); const layer = Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, telemetry.layer, cliConfig, @@ -72,13 +97,9 @@ describe("legacy functions new integration", () => { return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "hello-world", auth: "apikey" }); - const functionDir = join(workdir, "supabase", "functions", "hello-world"); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(functionDir, "index.ts"), "utf8"), - ); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const functionDir = path.join(workdir, "supabase", "functions", "hello-world"); + const entrypoint = yield* readText(path.join(functionDir, "index.ts")); + const config = yield* readText(path.join(workdir, "supabase", "config.toml")); expect(entrypoint).toContain('withSupabase({ auth: ["publishable", "secret"] }'); expect(entrypoint).toContain("--header 'apiKey: sb_publishable_"); @@ -86,14 +107,14 @@ describe("legacy functions new integration", () => { expect(config).toContain("[functions.hello-world]"); expect(config).toContain("verify_jwt = false"); expect(config).toContain('import_map = "./functions/hello-world/deno.json"'); - expect(readFileSync(join(functionDir, "deno.json"), "utf8")).toBe( + expect(yield* readText(path.join(functionDir, "deno.json"))).toBe( LEGACY_FUNCTIONS_NEW_DENO_JSON, ); - expect(readFileSync(join(functionDir, ".npmrc"), "utf8")).toBe(LEGACY_FUNCTIONS_NEW_NPMRC); + expect(yield* readText(path.join(functionDir, ".npmrc"))).toBe(LEGACY_FUNCTIONS_NEW_NPMRC); expect(out.stdoutText).toContain("Created new Function at "); - expect(out.stdoutText).toContain(join("supabase", "functions", "hello-world")); + expect(out.stdoutText).toContain(path.join("supabase", "functions", "hello-world")); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n]"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -102,12 +123,10 @@ describe("legacy functions new integration", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "public-fn", auth: "none" }); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "functions", "public-fn", "index.ts"), "utf8"), - ); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), + const entrypoint = yield* readText( + path.join(workdir, "supabase", "functions", "public-fn", "index.ts"), ); + const config = yield* readText(path.join(workdir, "supabase", "config.toml")); expect(entrypoint).toContain('withSupabase({ auth: "none" }'); expect(entrypoint).toContain("--header 'Content-Type: application/json'"); expect(config).toContain("verify_jwt = false"); @@ -118,12 +137,10 @@ describe("legacy functions new integration", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "user-fn", auth: "user" }); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "functions", "user-fn", "index.ts"), "utf8"), - ); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), + const entrypoint = yield* readText( + path.join(workdir, "supabase", "functions", "user-fn", "index.ts"), ); + const config = yield* readText(path.join(workdir, "supabase", "config.toml")); expect(entrypoint).toContain('withSupabase({ auth: "user" }'); expect(entrypoint).toContain("--header 'Authorization: Bearer <UserToken>'"); expect(config).toContain("verify_jwt = true"); @@ -133,27 +150,24 @@ describe("legacy functions new integration", () => { it.live("uses api.port and auth.publishable_key from config.toml when present", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase"), { recursive: true }).then(() => - writeFile( - join(workdir, "supabase", "config.toml"), - [ - 'project_id = "test-project"', - "", - "[api]", - "port = 54310", - "", - "[auth]", - 'publishable_key = "sb_publishable_custom"', - "", - ].join("\n"), - ), - ), + yield* makeDirectory(path.join(workdir, "supabase")); + yield* writeText( + path.join(workdir, "supabase", "config.toml"), + [ + 'project_id = "test-project"', + "", + "[api]", + "port = 54310", + "", + "[auth]", + 'publishable_key = "sb_publishable_custom"', + "", + ].join("\n"), ); yield* legacyFunctionsNew({ functionName: "customized", auth: "apikey" }); - const entrypoint = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "functions", "customized", "index.ts"), "utf8"), + const entrypoint = yield* readText( + path.join(workdir, "supabase", "functions", "customized", "index.ts"), ); expect(entrypoint).toContain("http://127.0.0.1:54310/functions/v1/customized"); expect(entrypoint).toContain("--header 'apiKey: sb_publishable_custom'"); @@ -163,16 +177,11 @@ describe("legacy functions new integration", () => { it.live("appends config even when the existing config.toml is malformed", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase"), { recursive: true }).then(() => - writeFile(join(workdir, "supabase", "config.toml"), "not valid toml ]["), - ), - ); + yield* makeDirectory(path.join(workdir, "supabase")); + yield* writeText(path.join(workdir, "supabase", "config.toml"), "not valid toml ]["); yield* legacyFunctionsNew({ functionName: "after-bad-config", auth: "none" }); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const config = yield* readText(path.join(workdir, "supabase", "config.toml")); expect(config).toContain("not valid toml ]["); expect(config).toContain("[functions.after-bad-config]"); }).pipe(Effect.provide(layer)); @@ -181,19 +190,14 @@ describe("legacy functions new integration", () => { it.live("warns and skips the config append when the function is already declared", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase"), { recursive: true }).then(() => - writeFile( - join(workdir, "supabase", "config.toml"), - ["[functions.hello-world]", "enabled = true", ""].join("\n"), - ), - ), + yield* makeDirectory(path.join(workdir, "supabase")); + yield* writeText( + path.join(workdir, "supabase", "config.toml"), + ["[functions.hello-world]", "enabled = true", ""].join("\n"), ); yield* legacyFunctionsNew({ functionName: "hello-world", auth: "apikey" }); - const config = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const config = yield* readText(path.join(workdir, "supabase", "config.toml")); expect(config.match(/\[functions\.hello-world\]/g) ?? []).toHaveLength(1); expect(out.stderrText).toContain("[functions.hello-world] is already declared in "); }).pipe(Effect.provide(layer)); @@ -202,18 +206,15 @@ describe("legacy functions new integration", () => { it.live("does not auto-generate IDE files when another function already exists", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase", "functions", "existing"), { recursive: true }).then(() => - writeFile( - join(workdir, "supabase", "functions", "existing", "index.ts"), - "// existing\n", - ), - ), + yield* makeDirectory(path.join(workdir, "supabase", "functions", "existing")); + yield* writeText( + path.join(workdir, "supabase", "functions", "existing", "index.ts"), + "// existing\n", ); yield* legacyFunctionsNew({ functionName: "second-fn", auth: "apikey" }); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* exists(path.join(workdir, ".idea", "deno.xml"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -222,29 +223,19 @@ describe("legacy functions new integration", () => { return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "with-yes", auth: "apikey" }); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("SUPABASE_YES=1 in the environment echoes the VS Code prompt and writes settings", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const { layer, out, workdir } = setup({ yes: false }); + const { layer, out, workdir } = setup({ yes: false, env: { SUPABASE_YES: "1" } }); return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "with-env-yes", auth: "apikey" }); // Established `--yes` branch bytes, reached through the env var — // not just the --yes flag. expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(true); + }).pipe(Effect.provide(layer)); }); it.live("piped `n` then `y` declines VS Code and writes IntelliJ settings (Go parity)", () => { @@ -256,8 +247,8 @@ describe("legacy functions new integration", () => { yield* legacyFunctionsNew({ functionName: "piped-idea", auth: "apikey" }); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] n"); expect(out.stderrText).toContain("Generate IntelliJ IDEA settings for Deno? [y/N] y"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(true); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* exists(path.join(workdir, ".idea", "deno.xml"))).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -269,8 +260,8 @@ describe("legacy functions new integration", () => { }); return Effect.gen(function* () { yield* legacyFunctionsNew({ functionName: "idea-fn", auth: "apikey" }); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(true); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* exists(path.join(workdir, ".idea", "deno.xml"))).toBe(true); expect(out.stdoutText).toContain("Generated IntelliJ settings in .idea/deno.xml."); }).pipe(Effect.provide(layer)); }); @@ -281,7 +272,7 @@ describe("legacy functions new integration", () => { yield* legacyFunctionsNew({ functionName: "json-fn", auth: "apikey" }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toMatchObject({ - path: join("supabase", "functions", "json-fn"), + path: path.join("supabase", "functions", "json-fn"), function_name: "json-fn", auth: "apikey", }); @@ -289,8 +280,8 @@ describe("legacy functions new integration", () => { // Machine formats are payload-only: the IDE prompt is suppressed and no IDE settings // are scaffolded as an undisclosed side effect. expect(out.stderrText).not.toContain("Generate VS Code settings"); - expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); - expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); + expect(yield* exists(path.join(workdir, ".vscode", "settings.json"))).toBe(false); + expect(yield* exists(path.join(workdir, ".idea", "deno.xml"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -300,7 +291,7 @@ describe("legacy functions new integration", () => { yield* legacyFunctionsNew({ functionName: "stream-fn", auth: "user" }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toMatchObject({ - path: join("supabase", "functions", "stream-fn"), + path: path.join("supabase", "functions", "stream-fn"), auth: "user", }); }).pipe(Effect.provide(layer)); @@ -318,10 +309,10 @@ describe("legacy functions new integration", () => { it.live("fails when the entrypoint already exists", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase", "functions", "dupe"), { recursive: true }).then(() => - writeFile(join(workdir, "supabase", "functions", "dupe", "index.ts"), "// existing\n"), - ), + yield* makeDirectory(path.join(workdir, "supabase", "functions", "dupe")); + yield* writeText( + path.join(workdir, "supabase", "functions", "dupe", "index.ts"), + "// existing\n", ); const exit = yield* Effect.exit(legacyFunctionsNew({ functionName: "dupe", auth: "apikey" })); expect(exitTag(exit)).toBe("LegacyFunctionsNewFileExistsError"); @@ -332,9 +323,7 @@ describe("legacy functions new integration", () => { const { layer, telemetry, workdir } = setup(); return Effect.gen(function* () { // A directory at the config.toml path makes the append write fail (EISDIR). - yield* Effect.tryPromise(() => - mkdir(join(workdir, "supabase", "config.toml"), { recursive: true }), - ); + yield* makeDirectory(path.join(workdir, "supabase", "config.toml")); const exit = yield* Effect.exit( legacyFunctionsNew({ functionName: "write-fail", auth: "apikey" }), ); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts index dd2d4c5305..ebc3333f64 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.command.ts @@ -7,15 +7,18 @@ import { serveFileWatcherLayer, } from "../../../../shared/functions/serve.ts"; import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyFunctionsServe } from "./serve.handler.ts"; const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const legacyFunctionsServeRuntimeLayer = Layer.mergeAll( serveFileWatcherLayer, cliConfig, + httpClient, legacyDebugLoggerLayer, legacyTelemetryStateLayer, commandRuntimeLayer(["functions", "serve"]), diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts index 9f66fe7a34..90ef2caf0a 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.handler.ts @@ -1,5 +1,4 @@ -import { Effect } from "effect"; -import { join } from "node:path"; +import { Effect, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyFunctionsGoConfigCompat } from "../../../shared/legacy-functions-go-config.ts"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -23,18 +22,20 @@ export const legacyFunctionsServe = Effect.fn("legacy.functions.serve")(function const cliConfig = yield* LegacyCliConfig; const runtimeInfo = yield* RuntimeInfo; const telemetryState = yield* LegacyTelemetryState; + const path = yield* Path.Path; const debug = yield* LegacyDebugFlag; const networkId = yield* LegacyNetworkIdFlag; + const goConfigCompat = yield* legacyFunctionsGoConfigCompat; yield* serveFunctions(flags, { projectRoot: cliConfig.workdir, - supabaseDir: join(cliConfig.workdir, "supabase"), + supabaseDir: path.join(cliConfig.workdir, "supabase"), flagCwd: runtimeInfo.cwd, platform: runtimeInfo.platform, debug, networkId, projectIdOverride: cliConfig.projectId, goViperCompat: true, - goConfigCompat: legacyFunctionsGoConfigCompat, + goConfigCompat, }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts index 4dab2dafd3..8e59752fbf 100644 --- a/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/legacy/commands/functions/serve/serve.integration.test.ts @@ -1,22 +1,28 @@ -import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs"; -import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + Clock, + ConfigProvider, Duration, Effect, Exit, + FileSystem, Fiber, Layer, Option, + Path, PubSub, Queue, + Schema, Sink, Stream, } from "effect"; +import * as Formatter from "effect/Formatter"; +import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { FetchHttpClient } from "effect/unstable/http"; import { beforeEach, vi } from "vitest"; import { @@ -27,6 +33,8 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { toDockerPath } from "../../../../shared/functions/functions-docker.ts"; +import type { FunctionsGoConfigCompat } from "../../../../shared/functions/functions-config.ts"; +import { serveFunctions } from "../../../../shared/functions/serve.ts"; import { mockOutput, mockProcessControl, @@ -45,6 +53,10 @@ import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-i import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import type { LegacyFunctionsServeFlags } from "./serve.handler.ts"; +const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const join = (...segments: ReadonlyArray<string>) => pathService.join(...segments); +const dirname = (pathname: string) => pathService.dirname(pathname); + const deployMockState = vi.hoisted(() => ({ runCalls: [] as Array<{ command: string; @@ -78,29 +90,42 @@ const deployMockState = vi.hoisted(() => ({ // any container runtime (neither docker nor podman on PATH), as opposed // to a spawned process exiting non-zero. | { failure: Error }), + runEffect: undefined as + | undefined + | (( + command: string, + args: ReadonlyArray<string>, + ) => Effect.Effect<void, PlatformError.PlatformError>), reset() { this.runCalls = []; this.networkCalls = []; this.volumeCalls = []; this.runHandler = undefined; + this.runEffect = undefined; }, })); -vi.mock("../../../../shared/functions/functions-docker.ts", async () => { - const actual = await vi.importActual< - typeof import("../../../../shared/functions/functions-docker.ts") - >("../../../../shared/functions/functions-docker.ts"); - const { Effect } = await import("effect"); - const { legacyGetRegistryImageUrl } = await import("../../../shared/legacy-docker-registry.ts"); - - return { +vi.mock("../../../../shared/functions/functions-docker.ts", () => + Promise.all([ + vi.importActual<typeof import("../../../../shared/functions/functions-docker.ts")>( + "../../../../shared/functions/functions-docker.ts", + ), + import("effect"), + import("../../../shared/legacy-docker-registry.ts"), + ]).then(([actual, { Effect, FileSystem }, { legacyGetRegistryImageUrl }]) => ({ ...actual, ensureDockerNetwork: (networkMode: string, projectId: string) => Effect.sync(() => { deployMockState.networkCalls.push({ networkMode, projectId }); }), - ensureDockerNamedVolume: (volumeName: string, projectId: string) => + ensureDockerNamedVolume: ( + volumeName: string, + projectId: string, + projectEnvValues?: Readonly<Record<string, string>>, + ) => Effect.sync(() => { + const bitbucketCloneDir = projectEnvValues?.BITBUCKET_CLONE_DIR; + if (bitbucketCloneDir !== undefined && bitbucketCloneDir.length > 0) return; deployMockState.volumeCalls.push({ volumeName, projectId }); }), // Stubbed to the pure registry-mapping step only, skipping the actual @@ -114,9 +139,10 @@ vi.mock("../../../../shared/functions/functions-docker.ts", async () => { resolveFunctionsDockerImage: ( image: string, projectEnvValues?: Readonly<Record<string, string>>, - ) => Effect.sync(() => legacyGetRegistryImageUrl(image, projectEnvValues)), + ) => Effect.sync(() => legacyGetRegistryImageUrl(image, projectEnvValues ?? {})), runChildProcess: (command: string, args: ReadonlyArray<string>, options?: unknown) => - Effect.suspend(() => { + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; const envFile = args.flatMap((value, index) => args[index - 1] === "--env-file" ? [value] : [], )[0]; @@ -131,39 +157,74 @@ vi.mock("../../../../shared/functions/functions-docker.ts", async () => { ...(typeof options === "object" && options !== null ? options : {}), ...(envFile === undefined ? {} - : { envFileContents: readFileSync(envFile, "utf8") }), + : { envFileContents: yield* fs.readFileString(envFile) }), ...(multilineEnvDir === undefined ? {} : { - multilineEnvScript: readFileSync( + multilineEnvScript: yield* fs.readFileString( join(multilineEnvDir, "multiline-env.sh"), - "utf8", ), multilineEnvFiles: Object.fromEntries( - readdirSync(join(multilineEnvDir, "values")) - .filter((name) => name.startsWith("env-")) - .map((name) => [ - name, - readFileSync(join(multilineEnvDir, "values", name), "utf8"), - ]), + yield* Effect.forEach( + (yield* fs.readDirectory(join(multilineEnvDir, "values"))).filter( + (name) => name.startsWith("env-"), + ), + (name) => + fs + .readFileString(join(multilineEnvDir, "values", name)) + .pipe(Effect.map((contents) => [name, contents] as const)), + ), ), }), }; deployMockState.runCalls.push({ command, args: [...args], options: enrichedOptions }); + const runEffect = deployMockState.runEffect?.(command, args); + if (runEffect !== undefined) { + yield* runEffect; + } const result = deployMockState.runHandler?.(command, args, options) ?? { exitCode: 0, stdout: "", stderr: "", }; - if ("pending" in result) return Effect.never; - if ("failure" in result) return Effect.fail(result.failure); - return Effect.succeed(result); + if ("pending" in result) return yield* Effect.never; + if ("failure" in result) return yield* Effect.fail(result.failure); + return yield* Effect.succeed(result); }), - }; -}); + })), +); const tempRoot = useLegacyTempWorkdir("supabase-functions-serve-int-"); +const FunctionsConfigSchema = Schema.Record( + Schema.String, + Schema.Struct({ + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + verifyJWT: Schema.optional(Schema.Boolean), + entrypointPath: Schema.optional(Schema.String), + importMapPath: Schema.optional(Schema.String), + staticFiles: Schema.optional(Schema.Array(Schema.String)), + }), +); +const FunctionsConfigCodec = Schema.fromJsonString(FunctionsConfigSchema); +const decodeFunctionsConfig = Schema.decodeSync(FunctionsConfigCodec); +const DenoConfigSchema = Schema.Struct({ + imports: Schema.optional(Schema.Record(Schema.String, Schema.String)), + importMap: Schema.optional(Schema.String), +}); +const DenoConfigCodec = Schema.fromJsonString(DenoConfigSchema); +const encodeDenoConfig = Schema.encodeSync(DenoConfigCodec); +const JwksSchema = Schema.Struct({ + keys: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)), +}); +const JwksCodec = Schema.fromJsonString(JwksSchema); +const decodeJwks = Schema.decodeSync(JwksCodec); +const encodeJwks = Schema.encodeSync(JwksCodec); +const OpenIdConfigurationSchema = Schema.Struct({ jwks_uri: Schema.String }); +const encodeOpenIdConfiguration = Schema.encodeSync( + Schema.fromJsonString(OpenIdConfigurationSchema), +); + // Root bypasses POSIX permission bits, so chmod-based failure tests can't run there. const isRoot = typeof process.getuid === "function" && process.getuid() === 0; @@ -194,45 +255,52 @@ function extractFlagValues(args: ReadonlyArray<string>, flag: string) { return args.flatMap((value, index) => (args[index - 1] === flag ? [value] : [])); } -async function extractDockerEnvEntries(call: { args: ReadonlyArray<string>; options: unknown }) { +function property(value: unknown, key: string): unknown { + return typeof value === "object" && value !== null && key in value + ? Reflect.get(value, key) + : undefined; +} + +function extractDockerEnvEntries(call: { + args: ReadonlyArray<string>; + options: unknown; +}): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> { const values = extractFlagValues(call.args, "-e"); if (values.some((value) => value.includes("="))) { - return values; + return Effect.succeed(values); } const envFile = extractFlagValues(call.args, "--env-file")[0]; if (envFile !== undefined) { - const options = - typeof call.options === "object" && call.options !== null ? call.options : undefined; - const envFileContents = - options !== undefined && "envFileContents" in options - ? (options.envFileContents as string | undefined) - : undefined; - const contents = envFileContents ?? (await readFile(envFile, "utf8")); - return contents - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); + const envFileContents = property(call.options, "envFileContents"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const contents = + typeof envFileContents === "string" ? envFileContents : yield* fs.readFileString(envFile); + return contents + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + }).pipe(Effect.provide(BunServices.layer)); } - const options = - typeof call.options === "object" && call.options !== null ? call.options : undefined; - const env = - options !== undefined && "env" in options - ? (options.env as Readonly<Record<string, string>> | undefined) - : undefined; - if (env === undefined) { - return values; - } - return values.map((name) => `${name}=${env[name] ?? ""}`); + const env = property(call.options, "env"); + return Effect.succeed( + typeof env === "object" && env !== null + ? values.map((name) => { + const value = property(env, name); + return `${name}=${typeof value === "string" ? value : ""}`; + }) + : values, + ); } function waitFor(condition: () => boolean, message: string) { return Effect.gen(function* () { - const deadline = Date.now() + 3_000; + const deadline = (yield* Clock.currentTimeMillis) + 3_000; while (!condition()) { - if (Date.now() >= deadline) { - return yield* Effect.fail(new Error(message)); + if ((yield* Clock.currentTimeMillis) >= deadline) { + return yield* new Cause.UnknownError(undefined, message); } yield* Effect.sleep(Duration.millis(20)); } @@ -346,6 +414,7 @@ function mockDockerLogSpawner(behaviors: ReadonlyArray<LogProcessBehavior>) { interface SetupOptions { readonly debug?: boolean; + readonly env?: Readonly<Record<string, string>>; readonly networkId?: Option.Option<string>; readonly projectId?: Option.Option<string>; readonly processControl?: @@ -362,7 +431,13 @@ function setupServe(options: SetupOptions = {}) { workdir: tempRoot.current, projectId: options.projectId ?? Option.none(), }); - const api = mockLegacyPlatformApiService({ v1: {} }); + const api = { + ...mockLegacyPlatformApiService({ v1: {} }), + // Remote-JWKS resolution is a direct HttpClient consumer. Keep the + // handler's test boundary on the platform Fetch layer so each test's + // global fetch spy remains authoritative. + httpClientLayer: FetchHttpClient.layer, + }; const processControl = options.processControl ?? mockProcessControl(); const fileWatcher = options.fileWatcher ?? mockFileWatcher(); const childSpawner = options.childSpawner ?? mockDockerLogSpawner([{ exitCode: 1 }]); @@ -379,8 +454,13 @@ function setupServe(options: SetupOptions = {}) { platform: "linux", }), processControl, + env: options.env, }), fileWatcher.layer, + BunServices.layer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: options.env ?? {}, preserveEmptyStrings: true }), + ), childSpawner.layer, Layer.succeed(LegacyDebugFlag, options.debug ?? false), Layer.succeed(LegacyNetworkIdFlag, options.networkId ?? Option.none()), @@ -389,21 +469,81 @@ function setupServe(options: SetupOptions = {}) { return { layer, out, telemetry, processControl, fileWatcher, childSpawner }; } -async function writeProjectConfig(content: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "config.toml"), content); +function writeProjectConfig(content: string): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, "supabase"), { recursive: true }); + yield* fs.writeFileString(join(tempRoot.current, "supabase", "config.toml"), content); + }).pipe(Effect.provide(BunServices.layer)); } -async function writeFunctionFile(slug: string, relativePath: string, contents: string) { +function writeFunctionFile( + slug: string, + relativePath: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError> { const pathname = join(tempRoot.current, "supabase", "functions", slug, relativePath); - await mkdir(dirname(pathname), { recursive: true }); - await writeFile(pathname, contents); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(pathname), { recursive: true }); + yield* fs.writeFileString(pathname, contents); + }).pipe(Effect.provide(BunServices.layer)); } -async function writeProjectFile(relativePath: string, contents: string) { +function writeProjectFile( + relativePath: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError> { const pathname = join(tempRoot.current, relativePath); - await mkdir(dirname(pathname), { recursive: true }); - await writeFile(pathname, contents); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(dirname(pathname), { recursive: true }); + yield* fs.writeFileString(pathname, contents); + }).pipe(Effect.provide(BunServices.layer)); +} + +function fileExists(pathname: string): Effect.Effect<boolean, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(pathname); + }).pipe(Effect.provide(BunServices.layer)); +} + +function realPath(pathname: string): Effect.Effect<string, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.realPath(pathname); + }).pipe(Effect.provide(BunServices.layer)); +} + +function makeDirectory( + pathname: string, + options: { readonly recursive?: boolean; readonly mode?: number } = {}, +): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(pathname, options); + }).pipe(Effect.provide(BunServices.layer)); +} + +function writeFileString( + pathname: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(pathname, contents); + }).pipe(Effect.provide(BunServices.layer)); +} + +function chmodPath( + pathname: string, + mode: number, +): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.chmod(pathname, mode); + }).pipe(Effect.provide(BunServices.layer)); } beforeEach(() => { @@ -434,30 +574,22 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("world", "index.ts", 'Deno.serve(() => new Response("world"))\n'); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + ["SHARED=shared", "GLOBAL_ONLY=global", ""].join("\n"), ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeFunctionFile( + "hello", + ".env", + ["SHARED=hello", "FUNCTION_ONLY=hello", "SUPABASE_SKIP=ignored", ""].join("\n"), ); - yield* Effect.promise(() => - writeFunctionFile("world", "index.ts", 'Deno.serve(() => new Response("world"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["SHARED=shared", "GLOBAL_ONLY=global", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - ".env", - ["SHARED=hello", "FUNCTION_ONLY=hello", "SUPABASE_SKIP=ignored", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("world", ".env", ["SHARED=world", "FUNCTION_ONLY=world", ""].join("\n")), + yield* writeFunctionFile( + "world", + ".env", + ["SHARED=world", "FUNCTION_ONLY=world", ""].join("\n"), ); const { layer, out } = setupServe({ childSpawner }); @@ -471,7 +603,7 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("SHARED=shared"); expect(envs).toContain("GLOBAL_ONLY=global"); const functionsConfig = envs.find((entry) => @@ -483,7 +615,7 @@ describe("legacy functions serve integration", () => { } expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), + decodeFunctionsConfig(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), ).toEqual({ hello: expect.objectContaining({ env: { SHARED: "hello", FUNCTION_ONLY: "hello" }, @@ -521,26 +653,16 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + ["SOURCE=shared", "GLOBAL_ONLY=global", ""].join("\n"), ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["SOURCE=shared", "GLOBAL_ONLY=global", ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", ".env", "INVALID-KEY=must-not-be-read\n"), - ); - yield* Effect.promise(() => - writeProjectFile( - "custom.env", - ["SOURCE=explicit", "EXPLICIT_ONLY=explicit", ""].join("\n"), - ), + yield* writeFunctionFile("hello", ".env", "INVALID-KEY=must-not-be-read\n"); + yield* writeProjectFile( + "custom.env", + ["SOURCE=explicit", "EXPLICIT_ONLY=explicit", ""].join("\n"), ); const { layer } = setupServe({ childSpawner }); @@ -557,7 +679,7 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("SOURCE=explicit"); expect(envs).toContain("EXPLICIT_ONLY=explicit"); expect(envs).not.toContain("GLOBAL_ONLY=global"); @@ -569,7 +691,7 @@ describe("legacy functions serve integration", () => { throw new Error("missing functions config env"); } expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), + decodeFunctionsConfig(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), ).toEqual({ hello: { verifyJWT: true, @@ -581,14 +703,10 @@ describe("legacy functions serve integration", () => { it.live("fails before starting the runtime when a Function env file is malformed", () => { return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); const functionEnvPath = join(tempRoot.current, "supabase", "functions", "hello", ".env"); - yield* Effect.promise(() => writeFunctionFile("hello", ".env", "API-KEY=secret-value\n")); + yield* writeFunctionFile("hello", ".env", "API-KEY=secret-value\n"); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -643,37 +761,31 @@ describe("legacy functions serve integration", () => { ]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/src/main.ts"', - 'import_map = "./functions/hello/deno.json"', - 'static_files = ["./shared/index.html"]', - "", - "[functions.disabled]", - "enabled = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "src/main.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - yield* Effect.promise(() => - writeProjectFile("supabase/shared/index.html", "<h1>hello</h1>\n"), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/src/main.ts"', + 'import_map = "./functions/hello/deno.json"', + 'static_files = ["./shared/index.html"]', + "", + "[functions.disabled]", + "enabled = false", + "", + ].join("\n"), ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ["HELLO=WORLD", "SUPABASE_SKIP=1", ""].join("\n"), - ), + yield* writeFunctionFile( + "hello", + "src/main.ts", + 'Deno.serve(() => new Response("hello"))\n', ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", ".temp", "edge-runtime-version"), "1.73.13\n"), + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + yield* writeProjectFile("supabase/shared/index.html", "<h1>hello</h1>\n"); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + ["HELLO=WORLD", "SUPABASE_SKIP=1", ""].join("\n"), ); + yield* writeProjectFile(join("supabase", ".temp", "edge-runtime-version"), "1.73.13\n"); const { layer, out, telemetry } = setupServe({ childSpawner }); @@ -741,7 +853,7 @@ describe("legacy functions serve integration", () => { "edge-runtime start --main-service=/root --port=8081 --policy=per_worker\n", ); - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("HELLO=WORLD"); expect(envs).not.toContain("SUPABASE_SKIP=1"); const functionsConfig = envs.find((entry) => @@ -753,7 +865,9 @@ describe("legacy functions serve integration", () => { } expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), + decodeFunctionsConfig( + functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length), + ), ).toEqual({ hello: { verifyJWT: true, @@ -810,7 +924,6 @@ describe("legacy functions serve integration", () => { }; let multilineEnvDirWhenLogsStarted: string | undefined; - let multilineEnvDirExistedWhenLogsStarted = false; const childSpawner = mockDockerLogSpawner([ { exitCode: 1, @@ -825,9 +938,6 @@ describe("legacy functions serve integration", () => { multilineEnvDirWhenLogsStarted = extractFlagValues(dockerRun.args, "-v") .find((value) => value.endsWith(":/root/.supabase/multiline-env:ro,Z")) ?.slice(0, -":/root/.supabase/multiline-env:ro,Z".length); - multilineEnvDirExistedWhenLogsStarted = - multilineEnvDirWhenLogsStarted !== undefined && - existsSync(multilineEnvDirWhenLogsStarted); }, }, ]); @@ -837,17 +947,11 @@ describe("legacy functions serve integration", () => { ); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - [`MULTILINE_SECRET="${multilineValue}"`, ""].join("\n"), - ), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + [`MULTILINE_SECRET="${multilineValue}"`, ""].join("\n"), ); const { layer } = setupServe({ childSpawner }); @@ -867,7 +971,7 @@ describe("legacy functions serve integration", () => { } expect(dockerRun.args).toContain( - legacyGetRegistryImageUrl(dockerfileServiceImage("edgeruntime")), + legacyGetRegistryImageUrl(dockerfileServiceImage("edgeruntime"), {}), ); expect(dockerRun.args.join(" ")).not.toContain(multilineValue); expect(dockerRun.args.join(" ")).not.toContain("EOF_ENV_0"); @@ -906,8 +1010,7 @@ describe("legacy functions serve integration", () => { if (multilineEnvDirWhenLogsStarted === undefined) { throw new Error("expected multiline env dir when docker logs started"); } - expect(multilineEnvDirExistedWhenLogsStarted).toBe(true); - expect(existsSync(multilineEnvDirWhenLogsStarted)).toBe(false); + expect(yield* fileExists(multilineEnvDirWhenLogsStarted)).toBe(false); }); }); @@ -950,21 +1053,19 @@ describe("legacy functions serve integration", () => { ); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", "functions", ".env"), ["HELLO=WORLD", ""].join("\n")), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + ["HELLO=WORLD", ""].join("\n"), ); // Simulate a stale directory left behind by an earlier run that DID have multiline secrets. - yield* Effect.promise(async () => { - await mkdir(join(staleMultilineEnvDir, "values"), { recursive: true, mode: 0o700 }); - await writeFile(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n"); - await writeFile(join(staleMultilineEnvDir, "values", "env-0"), "stale secret\n"); + yield* makeDirectory(join(staleMultilineEnvDir, "values"), { + recursive: true, + mode: 0o700, }); + yield* writeFileString(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n"); + yield* writeFileString(join(staleMultilineEnvDir, "values", "env-0"), "stale secret\n"); const { layer } = setupServe({ childSpawner }); @@ -974,7 +1075,7 @@ describe("legacy functions serve integration", () => { ); expect(error).toBeInstanceOf(Error); - expect(existsSync(staleMultilineEnvDir)).toBe(false); + expect(yield* fileExists(staleMultilineEnvDir)).toBe(false); const dockerRun = deployMockState.runCalls.find( (call) => call.command === "docker" && call.args[0] === "create", @@ -994,17 +1095,11 @@ describe("legacy functions serve integration", () => { it.live("fails before startup when a multiline env name is not a shell identifier", () => { return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ['FOO.BAR="line-1\nline-2"', ""].join("\n"), - ), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + ['FOO.BAR="line-1\nline-2"', ""].join("\n"), ); const { layer } = setupServe(); @@ -1028,13 +1123,9 @@ describe("legacy functions serve integration", () => { it.live("sanitizes dotenv parse failures from config env files", () => { return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => writeProjectFile(".env.development", "API-KEY=secret-value\n")); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeProjectFile(".env.development", "API-KEY=secret-value\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -1082,30 +1173,24 @@ describe("legacy functions serve integration", () => { ]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "unused-alias/": "../missing-shared/", - }, - }), - ), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + 'import_map = "./functions/hello/deno.json"', + "", + ].join("\n"), + ); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile( + "hello", + "deno.json", + encodeDenoConfig({ + imports: { + "unused-alias/": "../missing-shared/", + }, + }), ); const { layer } = setupServe({ childSpawner }); @@ -1156,31 +1241,23 @@ describe("legacy functions serve integration", () => { return Effect.gen(function* () { const externalImportMapPath = join(dirname(tempRoot.current), "shared-import-map.json"); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFile(externalImportMapPath, JSON.stringify({ imports: {} })), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - importMap: "../../../../shared-import-map.json", - }), - ), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + 'import_map = "./functions/hello/deno.json"', + "", + ].join("\n"), + ); + yield* writeFileString(externalImportMapPath, encodeDenoConfig({ imports: {} })); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile( + "hello", + "deno.json", + encodeDenoConfig({ + importMap: "../../../../shared-import-map.json", + }), ); const { layer } = setupServe({ childSpawner }); @@ -1203,7 +1280,7 @@ describe("legacy functions serve integration", () => { } // `buildDockerBinds` realpath-resolves host paths, so compare against the // resolved path (on macOS the temp dir lives under /var -> /private/var). - const resolvedExternalImportMapPath = realpathSync(externalImportMapPath); + const resolvedExternalImportMapPath = yield* realPath(externalImportMapPath); expect( extractFlagValues(dockerRun.args, "-v").some( (value) => @@ -1244,42 +1321,34 @@ describe("legacy functions serve integration", () => { return Effect.gen(function* () { const sharedPath = join(tempRoot.current, "packages", "shared", "src", "index.ts"); - yield* Effect.promise(() => mkdir(join(tempRoot.current, ".git"), { recursive: true })); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - 'import_map = "./functions/hello/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile("packages/shared/src/index.ts", 'export const shared = "hello"\n'), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "index.ts", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile( - "hello", - "deno.json", - JSON.stringify({ - imports: { - "@repo/shared": "../../../packages/shared/src/index.ts", - }, - }), - ), + yield* makeDirectory(join(tempRoot.current, ".git"), { recursive: true }); + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + 'import_map = "./functions/hello/deno.json"', + "", + ].join("\n"), + ); + yield* writeProjectFile("packages/shared/src/index.ts", 'export const shared = "hello"\n'); + yield* writeFunctionFile( + "hello", + "index.ts", + [ + 'import { shared } from "@repo/shared"', + "Deno.serve(() => new Response(shared))", + "", + ].join("\n"), + ); + yield* writeFunctionFile( + "hello", + "deno.json", + encodeDenoConfig({ + imports: { + "@repo/shared": "../../../packages/shared/src/index.ts", + }, + }), ); const { layer } = setupServe({ childSpawner }); @@ -1300,7 +1369,7 @@ describe("legacy functions serve integration", () => { if (dockerRun === undefined) { throw new Error("expected docker create invocation"); } - const resolvedSharedPath = realpathSync(sharedPath); + const resolvedSharedPath = yield* realPath(sharedPath); expect( extractFlagValues(dockerRun.args, "-v").some( (value) => @@ -1338,13 +1407,9 @@ describe("legacy functions serve integration", () => { ]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer, out } = setupServe({ fileWatcher, childSpawner }); const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -1431,13 +1496,9 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ pending: true }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer, out } = setupServe({ processControl, childSpawner }); const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -1484,12 +1545,7 @@ describe("legacy functions serve integration", () => { }; return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( - () => - new Promise<Response>(() => { - // Intentionally pending — must never be reached before the assertion. - }), - ); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(() => Promise.race([])); yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -1497,22 +1553,18 @@ describe("legacy functions serve integration", () => { }), ); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example.com"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example.com"', + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer, out } = setupServe({ processControl }); const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -1587,17 +1639,14 @@ describe("legacy functions serve integration", () => { ); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ processControl, childSpawner }); const fiber = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), + Effect.mapError((error) => (error instanceof Error ? error : new Error(String(error)))), Effect.forkChild({ startImmediately: true }), ); @@ -1608,7 +1657,7 @@ describe("legacy functions serve integration", () => { ), "timed out waiting for Kong reload to start", ); - expect(existsSync(stagingDir)).toBe(true); + expect(yield* fileExists(stagingDir)).toBe(true); processControl.signal("SIGINT"); const exit = yield* Fiber.await(fiber); @@ -1623,7 +1672,7 @@ describe("legacy functions serve integration", () => { call.args.includes("supabase_edge_runtime_test-project"), ), ).toBe(true); - expect(existsSync(stagingDir)).toBe(false); + expect(yield* fileExists(stagingDir)).toBe(false); }); }, ); @@ -1651,13 +1700,9 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "inspect failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ debug: true, @@ -1695,7 +1740,7 @@ describe("legacy functions serve integration", () => { expect(commandScript).toContain("--inspect-main"); expect(commandScript).toContain("--verbose"); - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("SUPABASE_INTERNAL_DEBUG=true"); expect(envs).toContain("SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=0"); expect(deployMockState.networkCalls).toEqual([ @@ -1718,12 +1763,8 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "template logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); const { layer } = setupServe({ childSpawner }); yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); @@ -1793,21 +1834,17 @@ describe("legacy functions serve integration", () => { ]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime]", - 'policy = "per_worker"', - "inspector_port = 9229", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[edge_runtime]", + 'policy = "per_worker"', + "inspector_port = 9229", + "", + ].join("\n"), + ); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); const { layer } = setupServe({ childSpawner }); yield* legacyFunctionsServe(baseFlags({ inspect: true })).pipe( @@ -1863,22 +1900,29 @@ describe("legacy functions serve integration", () => { }, ]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; if (url === "https://issuer.example/.well-known/openid-configuration") { - return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); + return Promise.resolve( + new Response( + encodeOpenIdConfiguration({ jwks_uri: "https://issuer.example/jwks.json" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); } if (url === "https://issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); + return Promise.resolve( + new Response(encodeJwks({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); } - throw new Error(`unexpected fetch url: ${url}`); + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); }); yield* Effect.addFinalizer(() => @@ -1887,22 +1931,18 @@ describe("legacy functions serve integration", () => { }), ); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example"', + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -1925,14 +1965,14 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); expect(jwks).toBeDefined(); if (jwks === undefined) { throw new Error("missing SUPABASE_JWKS"); } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ + expect(decodeJwks(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ keys: expect.arrayContaining([ expect.objectContaining({ kid: "remote-key" }), expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), @@ -1967,9 +2007,9 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "jwks logs failed" }]); - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("oidc discovery failed"); - }); + const fetchMock = vi + .spyOn(globalThis, "fetch") + .mockImplementation(() => Promise.reject(new Error("oidc discovery failed"))); yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -1977,22 +2017,18 @@ describe("legacy functions serve integration", () => { }), ); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example"', + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2013,13 +2049,13 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); expect(jwks).toBeDefined(); if (jwks === undefined) { throw new Error("missing SUPABASE_JWKS"); } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ + expect(decodeJwks(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ keys: expect.arrayContaining([ expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), expect.objectContaining({ kty: "oct" }), @@ -2066,24 +2102,20 @@ describe("legacy functions serve integration", () => { }), ); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[auth]", - "enabled = false", - "", - "[auth.third_party.workos]", - "enabled = true", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth]", + "enabled = false", + "", + "[auth.third_party.workos]", + "enabled = true", + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2105,13 +2137,13 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); expect(jwks).toBeDefined(); if (jwks === undefined) { throw new Error("missing SUPABASE_JWKS"); } - expect(JSON.parse(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ + expect(decodeJwks(jwks.slice("SUPABASE_JWKS=".length))).toEqual({ keys: expect.arrayContaining([ expect.objectContaining({ kid: "b81269f1-21d8-4f2e-b719-c2240a840d90" }), expect.objectContaining({ kty: "oct" }), @@ -2144,25 +2176,21 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime]", - 'policy = "per_worker"', - "inspector_port = 8083", - "", - "[edge_runtime.secrets]", - 'FROM_CONFIG = "config-value"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[edge_runtime]", + 'policy = "per_worker"', + "inspector_port = 8083", + "", + "[edge_runtime.secrets]", + 'FROM_CONFIG = "config-value"', + "", + ].join("\n"), + ); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2183,7 +2211,7 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("FROM_CONFIG=config-value"); }); }); @@ -2216,23 +2244,19 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "secrets logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[edge_runtime.secrets]", - 'my_lower_secret = "keep-me"', - 'EMPTY_SECRET = ""', - 'UNRESOLVED_SECRET = "env(SERVE_SECRET_NEVER_SET)"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[edge_runtime.secrets]", + 'my_lower_secret = "keep-me"', + 'EMPTY_SECRET = ""', + 'UNRESOLVED_SECRET = "env(SERVE_SECRET_NEVER_SET)"', + "", + ].join("\n"), + ); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2253,7 +2277,7 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("MY_LOWER_SECRET=keep-me"); expect(envs.some((entry) => entry.startsWith("my_lower_secret="))).toBe(false); expect(envs.some((entry) => entry.startsWith("EMPTY_SECRET="))).toBe(false); @@ -2285,27 +2309,14 @@ describe("legacy functions serve integration", () => { return Effect.gen(function* () { const envName = "SUPABASE_SERVE_PROJECT_ID"; - const previous = process.env[envName]; - process.env[envName] = "env-backed-project"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env[envName]; - } else { - process.env[envName] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeProjectConfig([`project_id = "env(${envName})"`, ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig([`project_id = "env(${envName})"`, ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); - const { layer } = setupServe({ childSpawner }); + const { layer } = setupServe({ + childSpawner, + env: { [envName]: "env-backed-project" }, + }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), Effect.flip, @@ -2362,27 +2373,23 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "config-project"', - "", - "[functions.hello]", - "verify_jwt = true", - "", - "[remotes.override]", - 'project_id = "overrideprojectaaaaa"', - "", - "[remotes.override.functions.hello]", - "verify_jwt = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "config-project"', + "", + "[functions.hello]", + "verify_jwt = true", + "", + "[remotes.override]", + 'project_id = "overrideprojectaaaaa"', + "", + "[remotes.override.functions.hello]", + "verify_jwt = false", + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner, @@ -2425,7 +2432,7 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); const functionsConfig = envs.find((entry) => entry.startsWith("SUPABASE_INTERNAL_FUNCTIONS_CONFIG="), ); @@ -2435,7 +2442,9 @@ describe("legacy functions serve integration", () => { } expect( - JSON.parse(functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length)), + decodeFunctionsConfig( + functionsConfig.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length), + ), ).toEqual( expect.objectContaining({ hello: expect.objectContaining({ @@ -2447,31 +2456,202 @@ describe("legacy functions serve integration", () => { }, ); - it.live("fails inspect flag conflicts before startup work begins", () => { - return Effect.gen(function* () { - const { layer } = setupServe(); - const error = yield* legacyFunctionsServe( - baseFlags({ - inspect: true, - inspectMode: Option.some("run"), - }), - ).pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(Error); - if (error instanceof Error) { - expect(error.message).toContain( - "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", - ); + it.live("prefers a non-empty project dotenv override over configured project_id", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); } - expect(deployMockState.runCalls).toHaveLength(0); - expect(deployMockState.volumeCalls).toHaveLength(0); + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + yield* writeProjectConfig('project_id = "config-project"\n'); + yield* writeProjectFile(".env", "SUPABASE_PROJECT_ID=ambient-project\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, + ); + + expect(error).toBeInstanceOf(Error); + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_ambient-project", + projectId: "ambient-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_ambient-project", + projectId: "ambient-project", + }, + ]); + }); + }); + + it.live("prefers an explicit project-id override over the Go config project id", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + const goConfigCompat: FunctionsGoConfigCompat = { + load: () => + Effect.succeed({ + loaded: null, + projectEnvValues: {}, + projectId: "configured-project", + denoVersion: 2, + }), + }; + + return Effect.gen(function* () { + yield* writeProjectConfig('project_id = "configured-project"\n'); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ childSpawner }); + const error = yield* serveFunctions(baseFlags(), { + projectRoot: tempRoot.current, + supabaseDir: join(tempRoot.current, "supabase"), + flagCwd: tempRoot.current, + platform: "linux", + debug: false, + networkId: Option.none(), + projectIdOverride: Option.some("explicit-project"), + goViperCompat: true, + goConfigCompat, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_explicit-project", + projectId: "explicit-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_explicit-project", + projectId: "explicit-project", + }, + ]); + }); + }); + + it.live("ignores an empty project-id override", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); + + return Effect.gen(function* () { + yield* writeProjectConfig('project_id = "configured-project"\n'); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ childSpawner }); + const error = yield* serveFunctions(baseFlags(), { + projectRoot: tempRoot.current, + supabaseDir: join(tempRoot.current, "supabase"), + flagCwd: tempRoot.current, + platform: "linux", + debug: false, + networkId: Option.none(), + projectIdOverride: Option.some(""), + goViperCompat: true, + goConfigCompat: undefined, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_configured-project", + projectId: "configured-project", + }, + ]); + expect(deployMockState.networkCalls).toEqual([ + { + networkMode: "supabase_network_configured-project", + projectId: "configured-project", + }, + ]); + }); + }); + + it.live("fails inspect flag conflicts before startup work begins", () => { + return Effect.gen(function* () { + const { layer } = setupServe(); + const error = yield* legacyFunctionsServe( + baseFlags({ + inspect: true, + inspectMode: Option.some("run"), + }), + ).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain( + "if any flags in the group [inspect inspect-mode] are set none of the others can be; [inspect inspect-mode] were all set", + ); + } + expect(deployMockState.runCalls).toHaveLength(0); + expect(deployMockState.volumeCalls).toHaveLength(0); expect(deployMockState.networkCalls).toHaveLength(0); }); }); it.live("fails when the project config is malformed", () => { return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig("not valid toml ][")); + yield* writeProjectConfig("not valid toml ]["); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2479,7 +2659,7 @@ describe("legacy functions serve integration", () => { Effect.flip, ); - expect(JSON.stringify(error)).toContain("ProjectConfigParseError"); + expect(Formatter.formatJson(error)).toContain("ProjectConfigParseError"); expect(deployMockState.runCalls).toHaveLength(0); }); }); @@ -2503,13 +2683,9 @@ describe("legacy functions serve integration", () => { }; return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2543,13 +2719,9 @@ describe("legacy functions serve integration", () => { }; return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2601,13 +2773,9 @@ describe("legacy functions serve integration", () => { }; return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2640,7 +2808,7 @@ describe("legacy functions serve integration", () => { }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig("not valid toml ][")); + yield* writeProjectConfig("not valid toml ]["); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2672,10 +2840,10 @@ describe("legacy functions serve integration", () => { }; return Effect.gen(function* () { - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - throw new Error(`unexpected fetch before the DB assertion: ${url}`); + return Promise.reject(new Error(`unexpected fetch before the DB assertion: ${url}`)); }); yield* Effect.addFinalizer(() => @@ -2684,22 +2852,18 @@ describe("legacy functions serve integration", () => { }), ); - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "", - "[auth.third_party.workos]", - "enabled = true", - 'issuer_url = "https://issuer.example"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "", + "[auth.third_party.workos]", + "enabled = true", + 'issuer_url = "https://issuer.example"', + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2729,15 +2893,11 @@ describe("legacy functions serve integration", () => { }); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - ['project_id = "test-project"', "", "[auth]", 'jwt_secret = "short"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + ['project_id = "test-project"', "", "[auth]", 'jwt_secret = "short"', ""].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2755,7 +2915,7 @@ describe("legacy functions serve integration", () => { }); }); - it.live("resolves env() config values from root env development files", () => { + it.live("defaults an empty SUPABASE_ENV to root env development files", () => { deployMockState.runHandler = (command, args) => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); @@ -2775,24 +2935,120 @@ describe("legacy functions serve integration", () => { throw new Error(`unexpected docker args: ${args.join(" ")}`); }; - const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "root env logs failed" }]); - const previousSupabaseEnv = process.env["SUPABASE_ENV"]; + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "empty env logs failed" }]); + return Effect.gen(function* () { + yield* writeProjectConfig([`project_id = "env(ROOT_PROJECT_ID)"`, ""].join("\n")); + yield* writeProjectFile(".env.development", "ROOT_PROJECT_ID=root-env-project\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ + childSpawner, + env: { SUPABASE_ENV: "" }, + }); + const error = yield* serveFunctions(baseFlags(), { + projectRoot: tempRoot.current, + supabaseDir: join(tempRoot.current, "supabase"), + flagCwd: tempRoot.current, + platform: "linux", + debug: false, + networkId: Option.none(), + projectIdOverride: Option.none(), + goViperCompat: false, + goConfigCompat: undefined, + }).pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(Error); + if (error instanceof Error) { + expect(error.message).toContain("empty env logs failed"); + } + expect(deployMockState.volumeCalls).toEqual([ + { + volumeName: "supabase_edge_runtime_root-env-project", + projectId: "root-env-project", + }, + ]); + }); + }); + it.live("uses project dotenv Bitbucket settings for Serve volume creation and mounts", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; + + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "bitbucket logs failed" }]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig([`project_id = "env(ROOT_PROJECT_ID)"`, ""].join("\n")), - ); - yield* Effect.promise(() => - writeProjectFile(".env.development", "ROOT_PROJECT_ID=root-env-project\n"), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeProjectFile(".env", "BITBUCKET_CLONE_DIR=/opt/bitbucket\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ childSpawner }); + const error = yield* legacyFunctionsServe(baseFlags()).pipe( + Effect.provide(layer), + Effect.flip, ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + + expect(error).toBeInstanceOf(Error); + expect(deployMockState.volumeCalls).toEqual([]); + const dockerRun = deployMockState.runCalls.find( + (call) => call.command === "docker" && call.args[0] === "create", ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + expect(dockerRun).toBeDefined(); + if (dockerRun !== undefined) { + expect(extractFlagValues(dockerRun.args, "-v")).not.toContain( + "supabase_edge_runtime_test-project:/root/.cache/deno:rw", + ); + } + }); + }); - process.env["SUPABASE_ENV"] = "development"; + it.live("resolves env() config values from root env development files", () => { + deployMockState.runHandler = (command, args) => { + if (command !== "docker") { + throw new Error(`unexpected process: ${command}`); + } + if (args[0] === "container" && args[1] === "inspect") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "container" && args[1] === "rm") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + if (args[0] === "create" || args[0] === "cp" || args[0] === "start") { + return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; + } + if (args[0] === "exec") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`unexpected docker args: ${args.join(" ")}`); + }; - const { layer } = setupServe({ childSpawner }); + const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "root env logs failed" }]); + return Effect.gen(function* () { + yield* writeProjectConfig([`project_id = "env(ROOT_PROJECT_ID)"`, ""].join("\n")); + yield* writeProjectFile(".env.development", "ROOT_PROJECT_ID=root-env-project\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); + + const { layer } = setupServe({ + childSpawner, + env: { SUPABASE_ENV: "development" }, + }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), Effect.flip, @@ -2815,17 +3071,7 @@ describe("legacy functions serve integration", () => { projectId: "root-env-project", }, ]); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousSupabaseEnv === undefined) { - delete process.env["SUPABASE_ENV"]; - } else { - process.env["SUPABASE_ENV"] = previousSupabaseEnv; - } - }), - ), - ); + }); }); it.live( @@ -2853,23 +3099,18 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([ { exitCode: 1, stderr: "root api env logs failed" }, ]); - const previousSupabaseEnv = process.env["SUPABASE_ENV"]; - return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - ['project_id = "test-project"', "[api]", 'port = "env(ROOT_API_PORT)"', ""].join("\n"), - ), + yield* writeProjectConfig( + ['project_id = "test-project"', "[api]", 'port = "env(ROOT_API_PORT)"', ""].join("\n"), ); - yield* Effect.promise(() => writeProjectFile(".env.development", "ROOT_API_PORT=5544\n")); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - process.env["SUPABASE_ENV"] = "development"; + yield* writeProjectFile(".env.development", "ROOT_API_PORT=5544\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); - const { layer } = setupServe({ childSpawner }); + const { layer } = setupServe({ + childSpawner, + env: { SUPABASE_ENV: "development" }, + }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), Effect.flip, @@ -2888,19 +3129,9 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); expect(envs).toContain("SUPABASE_INTERNAL_HOST_PORT=5544"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousSupabaseEnv === undefined) { - delete process.env["SUPABASE_ENV"]; - } else { - process.env["SUPABASE_ENV"] = previousSupabaseEnv; - } - }), - ), - ); + }); }, ); @@ -2931,23 +3162,17 @@ describe("legacy functions serve integration", () => { ]); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "[auth]", - 'signing_keys_path = "./signing-keys.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", "signing-keys.json"), "[]\n"), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "[auth]", + 'signing_keys_path = "./signing-keys.json"', + "", + ].join("\n"), ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectFile(join("supabase", "signing-keys.json"), "[]\n"); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe({ childSpawner }); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -2968,16 +3193,14 @@ describe("legacy functions serve integration", () => { throw new Error("expected docker create call"); } - const envs = yield* Effect.promise(() => extractDockerEnvEntries(dockerRun)); + const envs = yield* extractDockerEnvEntries(dockerRun); const jwks = envs.find((entry) => entry.startsWith("SUPABASE_JWKS=")); expect(jwks).toBeDefined(); if (jwks === undefined) { throw new Error("missing SUPABASE_JWKS"); } - const parsed = JSON.parse(jwks.slice("SUPABASE_JWKS=".length)) as { - readonly keys: ReadonlyArray<Record<string, unknown>>; - }; + const parsed = decodeJwks(jwks.slice("SUPABASE_JWKS=".length)); expect( parsed.keys.some((key) => key["kid"] === "b81269f1-21d8-4f2e-b719-c2240a840d90"), ).toBe(false); @@ -2988,13 +3211,9 @@ describe("legacy functions serve integration", () => { it.live("fails when the explicit env file is missing", () => { return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); const { layer } = setupServe(); const error = yield* legacyFunctionsServe( @@ -3025,23 +3244,33 @@ describe("legacy functions serve integration", () => { return { exitCode: 0, stdout: "", stderr: "" }; } if (args[0] === "container" && args[1] === "rm") { - writeFileSync(join(tempRoot.current, "supabase", "functions"), "not a directory\n"); return { exitCode: 0, stdout: "", stderr: "" }; } throw new Error(`unexpected docker args: ${args.join(" ")}`); }; + deployMockState.runEffect = (command, args) => { + if (command !== "docker" || args[0] !== "container" || args[1] !== "rm") { + return Effect.void; + } + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const functionsPath = join(tempRoot.current, "supabase", "functions"); + yield* fs.remove(functionsPath, { recursive: true, force: true }); + yield* fs.writeFileString(functionsPath, "not a directory\n"); + }).pipe(Effect.provide(BunServices.layer)); + }; return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - [ - 'project_id = "test-project"', - "[functions.hello]", - 'entrypoint = "./functions/hello/index.ts"', - "", - ].join("\n"), - ), + yield* writeProjectConfig( + [ + 'project_id = "test-project"', + "[functions.hello]", + 'entrypoint = "./functions/hello/index.ts"', + "", + ].join("\n"), ); + yield* makeDirectory(join("supabase", "functions"), { recursive: true }); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); const { layer, out } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -3085,21 +3314,13 @@ describe("legacy functions serve integration", () => { }; return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => - writeProjectFile( - join("supabase", "functions", ".env"), - ['FOO.BAR="line-1\nline-2"', ""].join("\n"), - ), - ); - yield* Effect.promise(() => - writeProjectFile(join("supabase", ".temp", "start-secrets"), "not a directory\n"), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeProjectFile( + join("supabase", "functions", ".env"), + ['FOO.BAR="line-1\nline-2"', ""].join("\n"), ); + yield* writeProjectFile(join("supabase", ".temp", "start-secrets"), "not a directory\n"); const { layer, out } = setupServe(); const exit = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -3140,9 +3361,11 @@ describe("legacy functions serve integration", () => { "fails before any Docker work when config.toml has an explicit empty project_id", () => { return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig('project_id = ""\n')); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig('project_id = ""\n'); + yield* writeFunctionFile( + "hello", + "index.ts", + 'Deno.serve(() => new Response("hello"))\n', ); const { layer } = setupServe(); @@ -3169,13 +3392,13 @@ describe("legacy functions serve integration", () => { // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` // branch (`config.go:1034-1062`). return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), + yield* writeProjectConfig( + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeFunctionFile( + "hello", + "index.ts", + 'Deno.serve(() => new Response("hello"))\n', ); const { layer } = setupServe(); @@ -3221,27 +3444,18 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); return Effect.gen(function* () { - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile( + "hello", + "index.ts", + 'Deno.serve(() => new Response("hello"))\n', ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); - const { layer } = setupServe({ childSpawner }); + const { layer } = setupServe({ + childSpawner, + env: { SUPABASE_EDGE_RUNTIME_DENO_VERSION: "1" }, + }); yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); const dockerRun = deployMockState.runCalls.find( @@ -3280,27 +3494,18 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); return Effect.gen(function* () { - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile( + "hello", + "index.ts", + 'Deno.serve(() => new Response("hello"))\n', ); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - - const { layer } = setupServe({ childSpawner }); + const { layer } = setupServe({ + childSpawner, + env: { SUPABASE_NETWORK_ID: "env-network" }, + }); yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); expect(deployMockState.networkCalls).toEqual([ @@ -3336,27 +3541,15 @@ describe("legacy functions serve integration", () => { const childSpawner = mockDockerLogSpawner([{ exitCode: 1, stderr: "serve logs failed" }]); return Effect.gen(function* () { - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-network"; - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_NETWORK_ID"]; - } else { - process.env["SUPABASE_NETWORK_ID"] = previous; - } - }), - ); - - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); - yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); + yield* writeFunctionFile("hello", "deno.json", '{"imports":{}}\n'); - const { layer } = setupServe({ childSpawner, networkId: Option.some("flag-network") }); + const { layer } = setupServe({ + childSpawner, + env: { SUPABASE_NETWORK_ID: "env-network" }, + networkId: Option.some("flag-network"), + }); yield* legacyFunctionsServe(baseFlags()).pipe(Effect.provide(layer), Effect.flip); expect(deployMockState.networkCalls).toEqual([ @@ -3373,16 +3566,12 @@ describe("legacy functions serve integration", () => { it.live("surfaces the real filesystem error when the fallback env file is unreadable", () => { return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); // A directory at the fallback path makes the read fail with a non-ENOENT error (EISDIR). - yield* Effect.promise(() => - mkdir(join(tempRoot.current, "supabase", "functions", ".env"), { recursive: true }), - ); + yield* makeDirectory(join(tempRoot.current, "supabase", "functions", ".env"), { + recursive: true, + }); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( @@ -3407,22 +3596,18 @@ describe("legacy functions serve integration", () => { "surfaces the real filesystem error when the env staging dir cannot be created", () => { return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig(['project_id = "test-project"', ""].join("\n")), - ); - yield* Effect.promise(() => - writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'), - ); + yield* writeProjectConfig(['project_id = "test-project"', ""].join("\n")); + yield* writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("hello"))\n'); // A read-only parent makes the per-container staging-dir mkdir fail with EACCES. const stagingRoot = join(tempRoot.current, "supabase", ".temp", "start-secrets"); - yield* Effect.promise(() => mkdir(stagingRoot, { recursive: true })); - yield* Effect.promise(() => chmod(stagingRoot, 0o555)); + yield* makeDirectory(stagingRoot, { recursive: true }); + yield* chmodPath(stagingRoot, 0o555); const { layer } = setupServe(); const error = yield* legacyFunctionsServe(baseFlags()).pipe( Effect.provide(layer), Effect.flip, - Effect.ensuring(Effect.promise(() => chmod(stagingRoot, 0o755))), + Effect.ensuring(chmodPath(stagingRoot, 0o755).pipe(Effect.ignore)), ); expect(error).toBeInstanceOf(Error); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts index d47c80207f..9b0236b0f8 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -1,3 +1,4 @@ +import { DateTime } from "effect"; import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; @@ -215,10 +216,16 @@ export function legacyParseBearerJwtExp(value: string): LegacyBearerJwtInstant { // `wholeSeconds` needs no fractional handling at all. Only the fractional-second // digits themselves need a dedicated integer field: truncate (not round) to 9 // digits, matching the truncation of excess fractional digits described above. - const parsedDate = new Date(0); - parsedDate.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); - parsedDate.setUTCHours(Number(hour), Number(minute), Number(second), 0); - const wholeSeconds = parsedDate.getTime() / 1000 - offsetSeconds; + const parsedDate = DateTime.makeUnsafe({ + year: Number(year), + month: Number(month), + day: Number(day), + hour: Number(hour), + minute: Number(minute), + second: Number(second), + millisecond: 0, + }); + const wholeSeconds = DateTime.toEpochMillis(parsedDate) / 1000 - offsetSeconds; const nanos = fraction === undefined ? 0 : Number(fraction.slice(0, 9).padEnd(9, "0")); return { wholeSeconds, nanos }; } diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts index e2e92cc9dc..4dd5f4ae4e 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Clock, Effect, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacySignJwtWithJwk } from "../../../shared/legacy-go-jwt.ts"; @@ -57,18 +57,16 @@ export const legacyGenBearerJwt = Effect.fn("legacy.gen.bearer-jwt")(function* ( return yield* Effect.gen(function* () { if (Option.isNone(flags.role)) { - return yield* Effect.fail( - new LegacyGenBearerJwtRoleRequiredError({ - message: `required flag(s) "role" not set`, - }), - ); + return yield* new LegacyGenBearerJwtRoleRequiredError({ + message: `required flag(s) "role" not set`, + }); } const role = flags.role.value; // Built directly from `Date.now()`'s integer milliseconds, NOT floored to whole // seconds — see `LegacyBearerJwtClaimsInput.nowInstant`'s own doc comment for why // pre-flooring here would shorten a sub-second `--valid-for`'s effective lifetime. - const nowMs = Date.now(); + const nowMs = yield* Clock.currentTimeMillis; const nowInstant = { wholeSeconds: Math.floor(nowMs / 1000), nanos: (nowMs % 1000) * 1_000_000, diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 3f8fee61c0..3e7c280420 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -1,9 +1,8 @@ import { generateKeyPairSync } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, ManagedRuntime, Option, Path, Schema } from "effect"; +import * as Formatter from "effect/Formatter"; import { CliOutput, Command } from "effect/unstable/cli"; import { importJWK, jwtVerify } from "jose"; @@ -22,15 +21,16 @@ import { useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; -import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; -import { legacyGenCommand } from "../gen.command.ts"; +import { legacyGenBearerJwtCommand } from "./bearer-jwt.command.ts"; import type { LegacyGenBearerJwtFlags } from "./bearer-jwt.command.ts"; import { legacyGenBearerJwt } from "./bearer-jwt.handler.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-bearer-jwt-int-"); @@ -41,6 +41,15 @@ const LEGACY_DEFAULT_SIGNING_KEY_PUBLIC = { y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", }; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const testPath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); + +const legacyTestRoot = Command.make("supabase").pipe( + Command.withSubcommands([legacyGenBearerJwtCommand]), + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), +); + function generateEcJwk(kid: string): Record<string, unknown> { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); const jwk = privateKey.export({ format: "jwk" }) as Record<string, unknown>; @@ -92,14 +101,21 @@ function setup(options: SetupOptions = {}) { return { layer, out, telemetry }; } -async function writeConfig(contents: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "config.toml"), contents); +function writeFixture(relativePath: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = testPath.join(tempRoot.current, "supabase"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString(testPath.join(directory, relativePath), contents); + }); +} + +function writeConfig(contents: string) { + return writeFixture("config.toml", contents); } -async function writeSigningKeys(contents: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), contents); +function writeSigningKeys(contents: string) { + return writeFixture("signing_keys.json", contents); } /** @@ -109,9 +125,8 @@ async function writeSigningKeys(contents: string) { * (`legacyResolveSigningKeysConfigPaths` must resolve an accurate * `ProjectEnvironment` and thread it through explicitly). */ -async function writeSupabaseEnvDevelopment(contents: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", ".env.development"), contents); +function writeSupabaseEnvDevelopment(contents: string) { + return writeFixture(".env.development", contents); } const baseFlags: LegacyGenBearerJwtFlags = { @@ -123,18 +138,13 @@ const baseFlags: LegacyGenBearerJwtFlags = { }; function decodeSegment(segment: string): unknown { - return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); + return decodeJson(Buffer.from(segment, "base64url").toString("utf8")); } function tokenFrom(out: { stdoutText: string }): string { return out.stdoutText.trimEnd(); } -const legacyTestRoot = Command.make("supabase").pipe( - Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), - Command.withSubcommands([legacyGenCommand]), -); - describe("legacy gen bearer-jwt integration", () => { it.live("mints a token with the built-in default ES256 key when no config exists", () => { const { layer, out } = setup(); @@ -172,7 +182,7 @@ describe("legacy gen bearer-jwt integration", () => { () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeConfig("[auth]\nenabled = true\n")); + yield* writeConfig("[auth]\nenabled = true\n"); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -275,7 +285,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt({ ...baseFlags, role: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtRoleRequiredError"); expect(json).toContain('required flag(s) \\"role\\" not set'); } @@ -297,16 +307,15 @@ describe("legacy gen bearer-jwt integration", () => { // the TS port picked up the PARENT directory's `signing_keys_path` // and prompted for a kid instead of falling back to the // unconfigured-default branch. - const nestedWorkdir = join(tempRoot.current, "nested", "deeper"); + const nestedWorkdir = testPath.join(tempRoot.current, "nested", "deeper"); const { layer, out } = setup({ workdir: nestedWorkdir, pipedAnswer: "" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(nestedWorkdir, { recursive: true }); // `writeConfig`/`writeSigningKeys` target `tempRoot.current` — the ANCESTOR of // `nestedWorkdir` — never `nestedWorkdir` itself. - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([generateEcJwk("ec-kid")]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([generateEcJwk("ec-kid")])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -333,7 +342,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt({ ...baseFlags, payload: "not json" })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtPayloadError"); expect(json).toContain("failed to parse payload:"); } @@ -345,7 +354,7 @@ describe("legacy gen bearer-jwt integration", () => { it.live("Branch A: accepts a pasted RS256 JWK from stdin", () => { const jwk = generateRsaJwk("rsa-kid"); - const { layer, out } = setup({ pipedAnswer: JSON.stringify(jwk) }); + const { layer, out } = setup({ pipedAnswer: encodeJson(jwk) }); return Effect.gen(function* () { yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -367,7 +376,7 @@ describe("legacy gen bearer-jwt integration", () => { // real TTY the terminal's own line-editing already echoes what was typed, so // `legacyConsolePromptText` must not double-echo it itself. const jwk = generateEcJwk("ec-kid"); - const { layer, out } = setup({ stdinIsTty: true, pipedAnswer: JSON.stringify(jwk) }); + const { layer, out } = setup({ stdinIsTty: true, pipedAnswer: encodeJson(jwk) }); return Effect.gen(function* () { yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -385,7 +394,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyParseError"); expect(json).toContain("failed to parse JWK:"); } @@ -398,7 +407,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyParseError"); expect(json).toContain("cannot unmarshal array into Go value of type config.JWK"); } @@ -411,7 +420,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "cannot unmarshal number into Go value of type config.JWK", ); } @@ -424,7 +433,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "cannot unmarshal string into Go value of type config.JWK", ); } @@ -437,7 +446,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "cannot unmarshal bool into Go value of type config.JWK", ); } @@ -456,7 +465,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtSignError"); expect(json).toContain("failed to convert JWK to private key: unsupported key type: "); } @@ -489,12 +498,12 @@ describe("legacy gen bearer-jwt integration", () => { () => { // `config.Algorithm.UnmarshalText` rejects anything other than // RS256/ES256 DURING JSON decode, before the JWK ever reaches signing. - const { layer } = setup({ pipedAnswer: JSON.stringify({ kty: "oct", alg: "HS256" }) }); + const { layer } = setup({ pipedAnswer: encodeJson({ kty: "oct", alg: "HS256" }) }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyParseError"); expect(json).toContain("failed to parse JWK: must be one of [RS256 ES256]"); } @@ -506,12 +515,12 @@ describe("legacy gen bearer-jwt integration", () => { "Branch A: accepts a pasted JWK missing alg entirely (validated later, at sign time, not decode time)", () => { const { alg: _alg, ...jwkWithoutAlg } = generateEcJwk("no-alg-kid"); - const { layer } = setup({ pipedAnswer: JSON.stringify(jwkWithoutAlg) }); + const { layer } = setup({ pipedAnswer: encodeJson(jwkWithoutAlg) }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtSignError"); expect(json).toContain("unsupported algorithm: "); } @@ -527,13 +536,13 @@ describe("legacy gen bearer-jwt integration", () => { // field fails outright on a non-string element rather than silently // dropping the field the way this normalizer previously did. const { layer } = setup({ - pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", key_ops: ["sign", 1] }), + pipedAnswer: encodeJson({ kty: "oct", alg: "ES256", key_ops: ["sign", 1] }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyParseError"); expect(json).toContain( "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.key_ops of type string", @@ -550,13 +559,13 @@ describe("legacy gen bearer-jwt integration", () => { // exact message — decoding into `config.JWK`'s `Extractable *bool` // field fails outright rather than silently dropping it. const { layer } = setup({ - pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", ext: "true" }), + pipedAnswer: encodeJson({ kty: "oct", alg: "ES256", ext: "true" }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyParseError"); expect(json).toContain( "failed to parse JWK: json: cannot unmarshal string into Go struct field JWK.ext of type bool", @@ -568,13 +577,13 @@ describe("legacy gen bearer-jwt integration", () => { it.live("Branch A: rejects a pasted JWK with a non-string kid", () => { const { layer } = setup({ - pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", kid: 123 }), + pipedAnswer: encodeJson({ kty: "oct", alg: "ES256", kid: 123 }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", ); } @@ -595,7 +604,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", ); } @@ -618,7 +627,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse JWK: must be one of [RS256 ES256]", ); } @@ -634,7 +643,7 @@ describe("legacy gen bearer-jwt integration", () => { // independently succeeds, the LAST one wins normally, same as any // other duplicated field. const { layer, out } = setup({ - pipedAnswer: JSON.stringify({ ...generateEcJwk("dup-alg-kid"), alg: "ES256" }).replace( + pipedAnswer: encodeJson({ ...generateEcJwk("dup-alg-kid"), alg: "ES256" }).replace( '"alg":"ES256"', '"alg":"RS256","alg":"ES256"', ), @@ -671,7 +680,7 @@ describe("legacy gen bearer-jwt integration", () => { Y: jwk.y, D: jwk.d, }; - const { layer, out } = setup({ pipedAnswer: JSON.stringify(caseVariantJwk) }); + const { layer, out } = setup({ pipedAnswer: encodeJson(caseVariantJwk) }); return Effect.gen(function* () { yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -702,7 +711,7 @@ describe("legacy gen bearer-jwt integration", () => { const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", ); } @@ -714,7 +723,7 @@ describe("legacy gen bearer-jwt integration", () => { "Branch A: a null field value is treated as absent, not a type mismatch (Go's encoding/json no-op)", () => { const { layer, out } = setup({ - pipedAnswer: JSON.stringify({ ...generateEcJwk("null-ext-kid"), ext: null }), + pipedAnswer: encodeJson({ ...generateEcJwk("null-ext-kid"), ext: null }), }); return Effect.gen(function* () { yield* legacyGenBearerJwt(baseFlags); @@ -738,19 +747,15 @@ describe("legacy gen bearer-jwt integration", () => { // silently dropping the field. const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeSigningKeys( - JSON.stringify([{ kty: "oct", alg: "ES256", kid: "k1", key_ops: ["sign", 1] }]), - ), + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys( + encodeJson([{ kty: "oct", alg: "ES256", kid: "k1", key_ops: ["sign", 1] }]), ); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain( "failed to decode signing keys: failed to parse response body: json: cannot unmarshal number into Go struct field JWK.key_ops of type string", @@ -769,17 +774,13 @@ describe("legacy gen bearer-jwt integration", () => { // collapsed) record. const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeSigningKeys('[{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}]'), - ); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys('[{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}]'); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain( "failed to decode signing keys: failed to parse response body: json: cannot unmarshal number into Go struct field JWK.kid of type string", @@ -794,17 +795,13 @@ describe("legacy gen bearer-jwt integration", () => { () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeSigningKeys('[{"kty":"oct","kid":"k1","alg":"HS256","alg":"ES256"}]'), - ); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys('[{"kty":"oct","kid":"k1","alg":"HS256","alg":"ES256"}]'); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain( "failed to decode signing keys: failed to parse response body: must be one of [RS256 ES256]", @@ -820,10 +817,8 @@ describe("legacy gen bearer-jwt integration", () => { const jwk = generateEcJwk("ec-kid"); const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([jwk])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -856,10 +851,8 @@ describe("legacy gen bearer-jwt integration", () => { }; const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([caseVariantJwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([caseVariantJwk])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -887,13 +880,9 @@ describe("legacy gen bearer-jwt integration", () => { const jwk = generateEcJwk("ec-kid"); const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "env(KEYS_PATH)"\n'), - ); - yield* Effect.tryPromise(() => - writeSupabaseEnvDevelopment("KEYS_PATH=./signing_keys.json\n"), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "env(KEYS_PATH)"\n'); + yield* writeSupabaseEnvDevelopment("KEYS_PATH=./signing_keys.json\n"); + yield* writeSigningKeys(encodeJson([jwk])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -908,15 +897,13 @@ describe("legacy gen bearer-jwt integration", () => { () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([{ kty: "oct" }]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([{ kty: "oct" }])); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtSignError"); expect(json).toContain("failed to convert JWK to private key: unsupported key type: oct"); } @@ -927,15 +914,13 @@ describe("legacy gen bearer-jwt integration", () => { it.live("Branch B: fails with an empty key type when the stored key omits kty entirely", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([{ alg: "ES256" }]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([{ alg: "ES256" }])); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to convert JWK to private key: unsupported key type: ", ); } @@ -950,17 +935,13 @@ describe("legacy gen bearer-jwt integration", () => { // decode. const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeSigningKeys(JSON.stringify([{ kty: "oct", alg: "HS256" }])), - ); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([{ kty: "oct", alg: "HS256" }])); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain( "failed to decode signing keys: failed to parse response body: must be one of [RS256 ES256]", @@ -984,15 +965,13 @@ describe("legacy gen bearer-jwt integration", () => { const { layer } = setup(); return Effect.gen(function* () { const validKey = generateEcJwk("k2"); - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([[], validKey]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([[], validKey])); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain("failed to decode signing keys: expected a JSON array of objects"); } @@ -1022,10 +1001,8 @@ describe("legacy gen bearer-jwt integration", () => { const validKey = generateEcJwk("valid-kid"); const { layer, out } = setup({ pipedAnswer: "valid-kid" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([validKey, null]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([validKey, null])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -1050,10 +1027,8 @@ describe("legacy gen bearer-jwt integration", () => { const validKey = generateEcJwk("valid-kid"); const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(`${JSON.stringify([validKey])} []`)); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(`${encodeJson([validKey])} []`); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -1077,10 +1052,8 @@ describe("legacy gen bearer-jwt integration", () => { const jwk = { ...generateEcJwk("null-key-ops-kid"), key_ops: ["sign", null] }; const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([jwk])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -1102,15 +1075,13 @@ describe("legacy gen bearer-jwt integration", () => { // `TypeError` instead of failing gracefully. const { layer } = setup({ stdinIsTty: true }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys("[]")); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys("[]"); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyPickerAbortedError"); expect(json).toContain("user aborted"); } @@ -1125,10 +1096,8 @@ describe("legacy gen bearer-jwt integration", () => { const rsaJwk = generateRsaJwk("rsa-kid"); const { layer, out } = setup({ pipedAnswer: "rsa-kid" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([ecJwk, rsaJwk])); yield* legacyGenBearerJwt({ ...baseFlags, role: Option.some("postgres") }); const token = tokenFrom(out); @@ -1143,15 +1112,13 @@ describe("legacy gen bearer-jwt integration", () => { () => { const { layer } = setup({ pipedAnswer: "test-key" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys("[]")); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys("[]"); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtKeyNotFoundError"); expect(json).toContain("signing key not found: test-key"); } @@ -1166,13 +1133,11 @@ describe("legacy gen bearer-jwt integration", () => { const { kid: _kid, ...unnamedKey } = generateEcJwk("unused"); const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); // `namedKey` is listed FIRST, but has a non-empty kid; `unnamedKey` (no kid // field at all -> "") is listed SECOND. A blank answer must still resolve to // `unnamedKey` via the exact-match loop, not to `namedKey` via "return first". - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([namedKey, unnamedKey]))); + yield* writeSigningKeys(encodeJson([namedKey, unnamedKey])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -1189,10 +1154,8 @@ describe("legacy gen bearer-jwt integration", () => { const rsaJwk = generateRsaJwk("rsa-kid"); const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["1"] }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([ecJwk, rsaJwk])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -1212,10 +1175,8 @@ describe("legacy gen bearer-jwt integration", () => { const { kid: _kid, alg: _alg, ...bareKey } = generateEcJwk("unused"); const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["0"] }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([bareKey]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([bareKey])); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); // No `alg` at all fails downstream in the shared signer ("unsupported @@ -1239,10 +1200,8 @@ describe("legacy gen bearer-jwt integration", () => { }; const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["0"] }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([jwk])); yield* legacyGenBearerJwt(baseFlags); expect(out.promptSelectCalls[0]?.options[0]?.hint).toBe("ES256 (sign,verify)"); @@ -1256,10 +1215,8 @@ describe("legacy gen bearer-jwt integration", () => { const otherJwk = generateEcJwk("configured-kid"); const { layer, out } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([otherJwk]))); + yield* writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([otherJwk])); yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -1281,15 +1238,15 @@ describe("legacy gen bearer-jwt integration", () => { const otherJwk = generateEcJwk("configured-kid"); const { layer } = setup({ pipedAnswer: "configured-kid" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([otherJwk]))); + yield* writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys(encodeJson([otherJwk])); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("signing key not found: configured-kid"); + expect(Formatter.formatJson(exit.cause)).toContain( + "signing key not found: configured-kid", + ); } }).pipe(Effect.provide(layer)); }, @@ -1298,14 +1255,12 @@ describe("legacy gen bearer-jwt integration", () => { it.live("fails when signing_keys_path is configured but the file is missing", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtReadError"); expect(json).toContain("failed to read signing keys"); } @@ -1315,15 +1270,13 @@ describe("legacy gen bearer-jwt integration", () => { it.live("fails when the configured signing keys file is not valid JSON at all", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys("not valid json {")); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys("not valid json {"); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain("failed to decode signing keys:"); } @@ -1333,15 +1286,13 @@ describe("legacy gen bearer-jwt integration", () => { it.live("fails when the configured signing keys file is not a JSON array at all", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys("{}")); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys("{}"); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain("expected a JSON array"); } @@ -1351,15 +1302,13 @@ describe("legacy gen bearer-jwt integration", () => { it.live("fails when the configured signing keys file is a JSON array of non-objects", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => writeSigningKeys("[1, 2]")); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); + yield* writeSigningKeys("[1, 2]"); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenBearerJwtDecodeError"); expect(json).toContain("expected a JSON array of objects"); } @@ -1369,12 +1318,12 @@ describe("legacy gen bearer-jwt integration", () => { it.live("fails with a config parse error when config.toml is malformed", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeConfig("not valid toml ][")); + yield* writeConfig("not valid toml ]["); const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyGenBearerJwtConfigParseError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyGenBearerJwtConfigParseError"); } }).pipe(Effect.provide(layer)); }); @@ -1390,9 +1339,7 @@ describe("legacy gen bearer-jwt integration", () => { it.live("flushes telemetry state even when the signing-key resolution fails", () => { const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); + yield* writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'); // No signing_keys.json written -> LegacyGenBearerJwtReadError. yield* Effect.exit(legacyGenBearerJwt(baseFlags)); expect(telemetry?.flushed).toBe(true); @@ -1415,8 +1362,8 @@ describe("legacy gen bearer-jwt integration", () => { Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ - configDir: join(tempRoot.current, ".supabase"), - tracesDir: join(tempRoot.current, ".supabase", "traces"), + configDir: testPath.join(tempRoot.current, ".supabase"), + tracesDir: testPath.join(tempRoot.current, ".supabase", "traces"), consent: "granted", showDebug: false, deviceId: "test-device-id", @@ -1430,22 +1377,20 @@ describe("legacy gen bearer-jwt integration", () => { cliVersion: "0.1.0", }), ), + makeLegacyViperEnvLayer(), ); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "gen", "bearer-jwt", "--role", "service_role", - "--workdir", - tempRoot.current, ]); const token = tokenFrom(out); const [, payload] = token.split("."); const claims = decodeSegment(payload ?? "") as Record<string, unknown>; expect(claims["role"]).toBe("service_role"); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 0fbc88c385..b8767d9239 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Effect, Option, Schema } from "effect"; import { assertNoMalformedDuplicateJwkField, legacyReadSigningKeysFile, @@ -31,6 +31,7 @@ import { /** Established console read-line timeouts. */ const GO_CONSOLE_TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; const GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS = 100; +const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); /** * Writes `label` to stderr with NO trailing newline, reads one line bounded @@ -123,16 +124,13 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { if (input.length === 0) { return LEGACY_DEFAULT_SIGNING_KEY; } - let parsed: unknown; - try { - parsed = JSON.parse(input); - } catch (cause) { - return yield* Effect.fail( + const parsed = yield* Effect.try({ + try: () => decodeJsonString(input), + catch: (cause) => new LegacyGenBearerJwtKeyParseError({ message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, }), - ); - } + }); // A JSON `null` answer decodes into a ZERO-VALUE `config.JWK{}`: // `json.Unmarshal([]byte("null"), &key)` where `key` is a non-pointer // struct is a documented no-op (it leaves every field at its zero value: @@ -146,27 +144,23 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { return normalizeStoredJwk({}); } if (typeof parsed !== "object" || Array.isArray(parsed)) { - return yield* Effect.fail( - new LegacyGenBearerJwtKeyParseError({ - message: `failed to parse JWK: json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type config.JWK`, - }), - ); + return yield* new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type config.JWK`, + }); } - const record = parsed as Record<string, unknown>; + const record: Record<string, unknown> = Object.fromEntries(Object.entries(parsed)); // Case-insensitive lookup (`resolveJwkFieldValue`) — the `alg` allowlist // check (`config.Algorithm.UnmarshalText`) runs at JSON-decode time // regardless of the key's casing; see that function's doc comment in // `gen.signing-keys-config.ts`. const alg = resolveJwkFieldValue(record, "alg"); - try { - legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); - } catch (cause) { - return yield* Effect.fail( + yield* Effect.try({ + try: () => legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined), + catch: (cause) => new LegacyGenBearerJwtKeyParseError({ message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, }), - ); - } + }); // `normalizeStoredJwk` throws the established bare `encoding/json` // struct-field type-mismatch text (see its own doc comment) the moment // any OTHER field is malformed — e.g. @@ -257,9 +251,9 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( if (kid.length === 0 && availableKeys.length > 0) { return availableKeys[0]!; } - return yield* Effect.fail( - new LegacyGenBearerJwtKeyNotFoundError({ message: `signing key not found: ${kid}` }), - ); + return yield* new LegacyGenBearerJwtKeyNotFoundError({ + message: `signing key not found: ${kid}`, + }); } if (availableKeys.length === 0) { @@ -270,9 +264,7 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( // on an empty option list" behavior to lean on, and calling it with // zero options would otherwise resolve to an out-of-range index and // crash with a raw `TypeError` when `.kid` is accessed below. - return yield* Effect.fail( - new LegacyGenBearerJwtKeyPickerAbortedError({ message: "user aborted" }), - ); + return yield* new LegacyGenBearerJwtKeyPickerAbortedError({ message: "user aborted" }); } const output = yield* Output; diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index fbe5080f66..9aab29ac07 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -1,8 +1,9 @@ import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; -import { Effect, FileSystem, Option, Path } from "effect"; +import { Config, ConfigProvider, Effect, FileSystem, Option, Path, Schema } from "effect"; import { legacyAssertDecodableJwkAlgorithm } from "../../shared/legacy-go-jwt.ts"; import { legacyGoJsonKindName } from "../../shared/legacy-go-json.ts"; import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; +import { collectConfigEnvironment } from "../../../shared/runtime/config-environment.ts"; /** * Shared `[auth].signing_keys_path` config-loading logic for the `gen` command @@ -21,6 +22,8 @@ import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-proje export type LegacyStoredSigningKeyJwk = Readonly<Record<string, unknown>>; +const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + interface LegacyGenSigningKeysConfigPaths { /** CWD-relative `supabase/config.toml` (or the resolved config file's own display path). */ readonly configDisplayPath: string; @@ -489,18 +492,24 @@ export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* < // with it fine. Fills the exact same gap `legacy-local-project-context.ts`'s // `legacyLoadLocalProjectContext` already fills for `stop`/`status`, via the // same two-step resolution. + const supabaseEnv = yield* Config.option(Config.string("SUPABASE_ENV")).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + const provider = yield* ConfigProvider.ConfigProvider; + const shellEnv = yield* collectConfigEnvironment(provider); const projectEnv = yield* loadProjectEnvironment({ cwd, - baseEnv: process.env, + baseEnv: shellEnv, search: false, - skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + skipEnvLocal: Option.isSome(supabaseEnv) && supabaseEnv.value === "test", }).pipe( Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`)), ); - const projectEnvValues = yield* Effect.try({ - try: () => legacyResolveProjectEnvironmentValues(projectEnv, cwd), - catch: (cause) => onConfigParseError(`failed to read config: ${String(cause)}`), - }); + const projectEnvValues = yield* legacyResolveProjectEnvironmentValues( + projectEnv, + cwd, + shellEnv, + ).pipe(Effect.mapError((cause) => onConfigParseError(cause.message))); const loaded = yield* loadProjectConfig(cwd, { projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, goViperCompat: true, @@ -592,19 +601,11 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* <E1, E2>( const raw = yield* fs .readFileString(actualPath) .pipe(Effect.mapError((cause) => onReadError(`failed to read signing keys: ${String(cause)}`))); + // Decoding is a single `json.Decoder.Decode`-style call, which reads exactly + // ONE JSON value and never checks for trailing bytes. Parse only the first + // value's own source span to preserve that established behavior. const decoded = yield* Effect.try({ - // Decoding is a single `json.Decoder.Decode`-style call, which reads - // exactly ONE JSON value and never checks for trailing bytes — content - // after that first value (even further syntactically-valid JSON, e.g. a - // `signing_keys_path` file containing `"[validKey] []"`) is silently - // ignored, not an error. Plain `JSON.parse` requires the ENTIRE string to - // be exactly one value and throws on anything left over, so parse only - // the first value's own source span — reusing the same - // {@link skipJsonValue} span-scanner {@link splitJsonArrayElementTexts} - // already uses below — to match the established decode-once-ignore-the-rest - // behavior: signing still succeeds with `validKey` from a - // `signing_keys_path` file containing `[validKey] []`. - try: () => JSON.parse(raw.slice(0, skipJsonValue(raw, 0))), + try: () => decodeJsonString(raw.slice(0, skipJsonValue(raw, 0))), catch: (cause) => onDecodeError(`failed to decode signing keys: ${String(cause)}`), }); if (!Array.isArray(decoded)) { @@ -638,29 +639,30 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* <E1, E2>( } const elementTexts = splitJsonArrayElementTexts(raw); const normalized: Array<Record<string, unknown>> = []; - for (const [index, item] of ( - decoded as ReadonlyArray<Record<string, unknown> | null> - ).entries()) { + const records = decoded.map((item): Record<string, unknown> | null => + item === null ? null : isRecord(item) ? item : {}, + ); + for (const [index, item] of records.entries()) { const record = item === null ? {} : item; const elementText = elementTexts[index]; - try { + yield* Effect.try({ // Case-insensitive lookup (`resolveJwkFieldValue`) — the `alg` // allowlist check (`config.Algorithm.UnmarshalText`) runs at // JSON-decode time regardless of the key's casing; see that // function's doc comment. - const alg = resolveJwkFieldValue(record, "alg"); - legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); - if (elementText !== undefined) { - assertNoMalformedDuplicateJwkField(elementText); - } - } catch (cause) { - return yield* Effect.fail( + try: () => { + const alg = resolveJwkFieldValue(record, "alg"); + legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); + if (elementText !== undefined) { + assertNoMalformedDuplicateJwkField(elementText); + } + }, + catch: (cause) => onDecodeError( `failed to decode signing keys: failed to parse response body: ${cause instanceof Error ? cause.message : String(cause)}`, ), - ); - } + }); normalized.push(record); } - return normalized as ReadonlyArray<LegacyStoredSigningKeyJwk>; + return normalized; }); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts index 6fe5144257..fa39ed8f77 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.e2e.test.ts @@ -1,11 +1,12 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { runSupabase } from "../../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); /** * Golden-path e2e for CLI-1865: exercises the real compiled-binary boundary — @@ -18,47 +19,73 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase gen signing-key (legacy)", () => { let projectDir: string; - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-gen-signing-key-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync( - join(projectDir, "supabase", "config.toml"), - '[auth]\nsigning_keys_path = "./signing_keys.json"\n', - ); - writeFileSync(join(projectDir, "supabase", "signing_keys.json"), "[]\n"); - }); + beforeEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const temp = yield* fs.makeTempDirectory({ prefix: "supabase-gen-signing-key-e2e-" }); + projectDir = temp; + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + '[auth]\nsigning_keys_path = "./signing_keys.json"\n', + ); + yield* fs.writeFileString(path.join(projectDir, "supabase", "signing_keys.json"), "[]\n"); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterEach(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); + afterEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); test( "declines the overwrite on a piped 'n' without crashing or writing the file", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + () => + runSupabase(["gen", "signing-key"], { entrypoint: "legacy", cwd: projectDir, stdin: "n\n", - }); - expect(exitCode).toBe(1); - expect(stderr).toContain("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); - expect(stderr).not.toContain("Service not found"); - const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); - expect(JSON.parse(saved)).toEqual([]); - }, + }).then((result) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("context canceled"); + expect(result.stderr).not.toContain("Try rerunning the command with --debug"); + expect(result.stderr).not.toContain("Service not found"); + const saved = yield* fs.readFileString( + path.join(projectDir, "supabase", "signing_keys.json"), + ); + expect(decodeJson(saved)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ), ); - test("overwrites on a piped 'y'", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { + test("overwrites on a piped 'y'", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["gen", "signing-key"], { entrypoint: "legacy", cwd: projectDir, stdin: "y\n", - }); - expect(exitCode).toBe(0); - expect(stderr).toContain("JWT signing key appended to:"); - const saved = readFileSync(join(projectDir, "supabase", "signing_keys.json"), "utf8"); - expect(JSON.parse(saved)).toHaveLength(1); - }); + }).then((result) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain("JWT signing key appended to:"); + const saved = yield* fs.readFileString( + path.join(projectDir, "supabase", "signing_keys.json"), + ); + expect(decodeJson(saved)).toHaveLength(1); + }).pipe(Effect.provide(BunServices.layer)), + ), + ), + ); }); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 55801ee1b5..d7174cdba7 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -1,6 +1,6 @@ import { generateKeyPairSync, randomUUID } from "node:crypto"; import { styleText } from "node:util"; -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Formatter, Option, Path } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; @@ -97,11 +97,9 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori }); const exported = privateKey.export({ format: "jwk" }); if (!isRecord(exported)) { - return yield* Effect.fail( - new LegacyGenSigningKeyGenerateError({ - message: "failed to generate signing key: rsa jwk export failed", - }), - ); + return yield* new LegacyGenSigningKeyGenerateError({ + message: "failed to generate signing key: rsa jwk export failed", + }); } return { kty: "RSA", @@ -124,11 +122,9 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); const exported = privateKey.export({ format: "jwk" }); if (!isRecord(exported)) { - return yield* Effect.fail( - new LegacyGenSigningKeyGenerateError({ - message: "failed to generate signing key: ec jwk export failed", - }), - ); + return yield* new LegacyGenSigningKeyGenerateError({ + message: "failed to generate signing key: ec jwk export failed", + }); } return { kty: "EC", @@ -185,7 +181,7 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const isGitIgnored = Effect.fnUntraced(function* (filePath: string, searchFrom: string) { const path = yield* Path.Path; - const gitRoot = yield* Effect.tryPromise(() => findGitRootPath(searchFrom)).pipe(Effect.orDie); + const gitRoot = yield* findGitRootPath(searchFrom).pipe(Effect.orDie); if (gitRoot === undefined) { return Option.none<boolean>(); } @@ -238,7 +234,7 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* const configured = signingKeysConfig.configured; if (Option.isNone(configured)) { - yield* output.raw(`${JSON.stringify(key)}\n`, "stdout"); + yield* output.raw(`${Formatter.formatJson(key)}\n`, "stdout"); const defaultPath = path.join("supabase", "signing_keys.json"); yield* emitSuccessTrailer( `\nTo enable JWT signing keys in your local project:\n1. Save the generated key to ${emphasize(defaultPath)}\n2. Update your ${emphasize(signingKeysConfig.configDisplayPath)} with the new keys path\n\n[auth]\nsigning_keys_path = "./signing_keys.json"\n\n`, @@ -273,17 +269,21 @@ export const legacyGenSigningKey = Effect.fn("legacy.gen.signing-key")(function* true, ); if (!confirmed) { - return yield* Effect.fail( - new LegacyGenSigningKeyCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyGenSigningKeyCancelledError({ + message: CONTEXT_CANCELED_MESSAGE, + }); } return [key]; }); yield* fs - .writeFileString(configured.value.actualPath, `${JSON.stringify(nextKeys, null, 2)}\n`, { - mode: 0o600, - }) + .writeFileString( + configured.value.actualPath, + `${Formatter.formatJson(nextKeys, { space: 2 })}\n`, + { + mode: 0o600, + }, + ) .pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index fcb6e334cd..100a7342ca 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -1,19 +1,22 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Layer, Option, Sink, Stream } from "effect"; -import { CliOutput, Command } from "effect/unstable/cli"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Schema, + Sink, + Stream, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - mockAnalytics, - mockOutput, - mockRuntimeInfo, - mockStdin, - mockTty, - processEnvLayer, -} from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, mockLegacyCliConfig, @@ -23,16 +26,36 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; -import { LEGACY_GLOBAL_FLAGS, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; -import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; -import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; -import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; -import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; -import { legacyGenCommand } from "../gen.command.ts"; +import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { legacyGenSigningKey } from "./signing-key.handler.ts"; import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); + +const SigningKeySchema = Schema.Struct({ + alg: Schema.optional(Schema.String), + kty: Schema.String, + use: Schema.optional(Schema.String), + kid: Schema.optional(Schema.String), + crv: Schema.optional(Schema.String), + x: Schema.optional(Schema.String), + y: Schema.optional(Schema.String), + d: Schema.optional(Schema.String), + n: Schema.optional(Schema.String), + e: Schema.optional(Schema.String), + p: Schema.optional(Schema.String), + q: Schema.optional(Schema.String), + dp: Schema.optional(Schema.String), + dq: Schema.optional(Schema.String), + qi: Schema.optional(Schema.String), +}); +const SigningKeyCodec = Schema.fromJsonString(SigningKeySchema); +const SigningKeysCodec = Schema.fromJsonString(Schema.Array(SigningKeySchema)); +const decodeSigningKey = Schema.decodeSync(SigningKeyCodec); +const decodeSigningKeys = Schema.decodeSync(SigningKeysCodec); +const encodeSigningKeys = Schema.encodeSync(SigningKeysCodec); interface SetupOptions { readonly format?: "text" | "json" | "stream-json"; @@ -47,6 +70,7 @@ interface SetupOptions { readonly pipedAnswer?: string; // Raw argv for `legacyResolveYes`'s explicit `--yes=false` detection. readonly cliArgs?: ReadonlyArray<string>; + readonly env?: Record<string, string>; } // `git check-ignore` is invoked via ChildProcessSpawner. Mock it with a controlled exit code so @@ -88,7 +112,14 @@ function setup(options: SetupOptions = {}) { }); const telemetry = options.trackTelemetry ? mockLegacyTelemetryStateTracked() : undefined; const layer = Layer.mergeAll( - buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), + buildLegacyTestRuntime({ + out, + api, + cliConfig, + tty, + telemetry: telemetry?.layer, + env: options.env, + }), Layer.succeed(LegacyYesFlag, options.yes ?? false), Layer.succeed(CliArgs, { args: options.cliArgs ?? [] }), mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), @@ -98,30 +129,71 @@ function setup(options: SetupOptions = {}) { }), // Listed after buildLegacyTestRuntime so it overrides the real spawner from BunServices. mockGitCheckIgnore(options.gitCheckIgnoreExitCode ?? 1), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: options.env ?? {}, preserveEmptyStrings: true }), + ), ); return { layer, out, telemetry }; } -async function writeConfig(contents: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "config.toml"), contents); +function writeConfig(contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, "supabase"), { recursive: true }); + yield* fs.writeFileString(join(tempRoot.current, "supabase", "config.toml"), contents); + }); } -async function writeJsonConfig(contents: string) { - await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); - await writeFile(join(tempRoot.current, "supabase", "config.json"), contents); +function writeJsonConfig(contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, "supabase"), { recursive: true }); + yield* fs.writeFileString(join(tempRoot.current, "supabase", "config.json"), contents); + }); } // `findGitRoot` walks up looking for a real `.git` entry, so the gitignore branch needs one to // exist; the `git check-ignore` call itself is mocked via `gitCheckIgnoreExitCode`. -async function initGitDir() { - await mkdir(join(tempRoot.current, ".git"), { recursive: true }); +function initGitDir() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, ".git"), { recursive: true }); + }); +} + +function writeText(path: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(fixturePath.dirname(path), { recursive: true }); + yield* fs.writeFileString(path, contents); + }); +} + +function readText(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }); +} + +function writeFile(path: string, contents: string) { + return writeText(path, contents); +} + +function readFile(path: string, _encoding: "utf8") { + return readText(path); } -const legacyTestRoot = Command.make("supabase").pipe( - Command.withSubcommands([legacyGenCommand]), - Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), -); +function mkdir(path: string, options: { readonly recursive?: boolean } = {}) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }); +} + +function runFixture<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { + return effect; +} describe("legacy gen signing-key integration", () => { it.live("prints a generated key to stdout when no signing_keys_path is configured", () => { @@ -129,7 +201,7 @@ describe("legacy gen signing-key integration", () => { return Effect.gen(function* () { yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - const parsed = JSON.parse(out.stdoutText) as Record<string, unknown>; + const parsed = decodeSigningKey(out.stdoutText); expect(parsed.alg).toBe("ES256"); expect(parsed.kty).toBe("EC"); expect(typeof parsed.kid).toBe("string"); @@ -144,64 +216,16 @@ describe("legacy gen signing-key integration", () => { return Effect.gen(function* () { yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); - const parsed = JSON.parse(out.stdoutText) as Record<string, unknown>; + const parsed = decodeSigningKey(out.stdoutText); expect(parsed.kty).toBe("RSA"); expect(parsed.alg).toBe("RS256"); expect(parsed.use).toBe("sig"); for (const field of ["n", "e", "d", "p", "q", "dp", "dq", "qi"]) { - expect(typeof parsed[field]).toBe("string"); + expect(typeof Reflect.get(parsed, field)).toBe("string"); } }).pipe(Effect.provide(layer)); }); - it.live("runs through the command wiring without missing runtime services", () => { - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const layer = Layer.mergeAll( - BunServices.layer, - processControlLayer, - CliOutput.layer(textCliOutputFormatter()), - out.layer, - analytics.layer, - processEnvLayer({ SUPABASE_HOME: tempRoot.current }), - mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), - mockTty({ stdinIsTty: false, stdoutIsTty: false }), - Layer.succeed(CliArgs, { args: [] }), - mockStdin(false), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: join(tempRoot.current, ".supabase"), - tracesDir: join(tempRoot.current, ".supabase", "traces"), - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), - ); - - return Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "gen", - "signing-key", - "--workdir", - tempRoot.current, - ]); - - const parsed = JSON.parse(out.stdoutText) as Record<string, unknown>; - expect(parsed.alg).toBe("ES256"); - expect(out.stderrText).toContain("To enable JWT signing keys in your local project:"); - }).pipe(Effect.provide(layer)) as Effect.Effect<void>; - }); - it.live( "ignores a stray config.json and uses the default config.toml path in the local setup hint (CLI-1961)", () => { @@ -214,7 +238,7 @@ describe("legacy gen signing-key integration", () => { // The CWD-relative `supabase/config.toml` is printed in this // "absent config" case; the hint must stay relative and must never leak the absolute // temp-dir path either. - yield* Effect.tryPromise(() => writeJsonConfig("{}\n")); + yield* writeJsonConfig("{}\n"); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); expect(out.stderrText).toContain(join("supabase", "config.toml")); @@ -229,19 +253,17 @@ describe("legacy gen signing-key integration", () => { () => { const { layer, out } = setup({ stdinIsTty: false }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture( writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); yield* legacyGenSigningKey({ algorithm: "RS256", append: false }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(1); expect(parsed[0]?.alg).toBe("RS256"); expect(out.stderrText).toContain("Do you want to overwrite the existing"); @@ -257,25 +279,21 @@ describe("legacy gen signing-key integration", () => { it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenSigningKeyCancelledError"); expect(json).toContain("context canceled"); } - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - expect(JSON.parse(saved)).toEqual([]); + expect(decodeSigningKeys(saved)).toEqual([]); // The non-TTY prompt echoes the piped answer back to stderr after the label. expect(out.stderrText).toContain("[Y/n] n\n"); }).pipe(Effect.provide(layer)); @@ -284,19 +302,15 @@ describe("legacy gen signing-key integration", () => { it.live("overwrites when a piped non-tty answer of 'y' is read", () => { const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "y" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(1); expect(out.stderrText).toContain("Do you want to overwrite the existing"); }).pipe(Effect.provide(layer)); @@ -305,12 +319,8 @@ describe("legacy gen signing-key integration", () => { it.live("passes an explicit default-yes prompt for interactive overwrite", () => { const { layer, out } = setup({ stdinIsTty: true }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); @@ -322,27 +332,25 @@ describe("legacy gen signing-key integration", () => { it.live("appends a new key when --append is set", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture( writeFile( join(tempRoot.current, "supabase", "signing_keys.json"), - `${JSON.stringify([ + encodeSigningKeys([ { kty: "EC", x: "existing-x", }, - ])}\n`, + ]), ), ); yield* legacyGenSigningKey({ algorithm: "ES256", append: true }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(2); expect(parsed[0]?.x).toBe("existing-x"); expect(parsed[1]?.alg).toBe("ES256"); @@ -358,10 +366,10 @@ describe("legacy gen signing-key integration", () => { it.live("does not fail on a malformed signing keys file when [auth] enabled is false", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => + yield* runFixture( writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), ); - yield* Effect.tryPromise(() => + yield* runFixture( writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "not valid json {\n"), ); @@ -382,22 +390,22 @@ describe("legacy gen signing-key integration", () => { () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => + yield* runFixture( writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), ); - yield* Effect.tryPromise(() => + yield* runFixture( writeFile( join(tempRoot.current, "supabase", "signing_keys.json"), - `${JSON.stringify([{ kty: "EC", kid: "existing-key", x: "existing-x" }])}\n`, + encodeSigningKeys([{ kty: "EC", kid: "existing-key", x: "existing-x" }]), ), ); yield* legacyGenSigningKey({ algorithm: "ES256", append: true }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(2); expect(parsed[0]?.kid).toBe(LEGACY_DEFAULT_SIGNING_KEY.kid); expect(parsed.some((key) => key["kid"] === "existing-key")).toBe(false); @@ -408,17 +416,15 @@ describe("legacy gen signing-key integration", () => { it.live("fails when the configured signing keys file is not a JSON array of objects", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture( writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[1]\n"), ); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenSigningKeyDecodeError"); expect(json).toContain("failed to decode signing keys"); } @@ -428,12 +434,12 @@ describe("legacy gen signing-key integration", () => { it.live("fails with a config parse error when config.toml is malformed", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeConfig("not valid toml ][")); + yield* writeConfig("not valid toml ]["); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyConfigParseError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyGenSigningKeyConfigParseError"); } }).pipe(Effect.provide(layer)); }); @@ -441,17 +447,13 @@ describe("legacy gen signing-key integration", () => { it.live("fails when the configured signing keys file is not a JSON array at all", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "{}\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "{}\n")); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenSigningKeyDecodeError"); expect(json).toContain("expected a JSON array"); } @@ -462,32 +464,42 @@ describe("legacy gen signing-key integration", () => { const { layer, out } = setup(); return Effect.gen(function* () { const absoluteKeysPath = join(tempRoot.current, "supabase", "absolute_keys.json"); - yield* Effect.tryPromise(() => - writeConfig(`[auth]\nsigning_keys_path = ${JSON.stringify(absoluteKeysPath)}\n`), - ); - yield* Effect.tryPromise(() => writeFile(absoluteKeysPath, "[]\n")); + yield* runFixture(writeConfig(`[auth]\nsigning_keys_path = "${absoluteKeysPath}"\n`)); + yield* writeFile(absoluteKeysPath, "[]\n"); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - const saved = yield* Effect.tryPromise(() => readFile(absoluteKeysPath, "utf8")); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const saved = yield* readFile(absoluteKeysPath, "utf8"); + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(1); // An absolute configured path is displayed verbatim, matching Go. expect(out.stderrText).toContain(absoluteKeysPath); }).pipe(Effect.provide(layer)); }); + it.live("resolves signing_keys_path from the injected shell environment", () => { + const { layer, out } = setup({ env: { KEYS_PATH: "./shell-signing-keys.json" } }); + return Effect.gen(function* () { + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "env(KEYS_PATH)"\n')); + const keysPath = join(tempRoot.current, "supabase", "shell-signing-keys.json"); + yield* writeFile(keysPath, "[]\n"); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + + expect(decodeSigningKeys(yield* readFile(keysPath, "utf8"))).toHaveLength(1); + expect(out.stderrText).toContain("supabase/shell-signing-keys.json"); + }).pipe(Effect.provide(layer)); + }); + it.live("fails when signing_keys_path is configured but the file is missing", () => { const { layer } = setup(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenSigningKeyReadError"); expect(json).toContain("failed to read signing keys"); } @@ -497,17 +509,13 @@ describe("legacy gen signing-key integration", () => { it.live("returns context canceled when a TTY user declines overwrite", () => { const { layer } = setup({ stdinIsTty: true, promptConfirmResponses: [false] }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenSigningKeyCancelledError"); expect(json).toContain("context canceled"); } @@ -518,13 +526,9 @@ describe("legacy gen signing-key integration", () => { // git check-ignore exits non-zero when the path is NOT ignored. const { layer, out } = setup({ gitCheckIgnoreExitCode: 1 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => initGitDir()); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* initGitDir(); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); @@ -540,11 +544,9 @@ describe("legacy gen signing-key integration", () => { // git check-ignore exits zero when the path IS ignored. const { layer, out } = setup({ gitCheckIgnoreExitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => initGitDir()); - yield* Effect.tryPromise(() => + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* initGitDir(); + yield* runFixture( writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); @@ -558,12 +560,8 @@ describe("legacy gen signing-key integration", () => { it.live("echoes [Y/n] y to stderr when --yes bypasses overwrite confirmation", () => { const { layer, out } = setup({ yes: true, stdinIsTty: true }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); @@ -579,24 +577,22 @@ describe("legacy gen signing-key integration", () => { () => { const { layer, out } = setup({ format: "json", stdinIsTty: true }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture( writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyGenSigningKeyCancelledError"); } - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - expect(JSON.parse(saved)).toEqual([]); + expect(decodeSigningKeys(saved)).toEqual([]); expect(out.promptConfirmCalls).toHaveLength(0); }).pipe(Effect.provide(layer)); }, @@ -610,23 +606,19 @@ describe("legacy gen signing-key integration", () => { it.live("honors a piped non-tty 'n' even when --output-format is json", () => { const { layer, out } = setup({ format: "json", stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); } - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - expect(JSON.parse(saved)).toEqual([]); + expect(decodeSigningKeys(saved)).toEqual([]); expect(out.promptConfirmCalls).toHaveLength(0); }).pipe(Effect.provide(layer)); }); @@ -634,19 +626,15 @@ describe("legacy gen signing-key integration", () => { it.live("honors a piped non-tty 'y' when --output-format is stream-json", () => { const { layer } = setup({ format: "stream-json", stdinIsTty: false, pipedAnswer: "y" }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - expect(JSON.parse(saved) as ReadonlyArray<unknown>).toHaveLength(1); + expect(decodeSigningKeys(saved)).toHaveLength(1); }).pipe(Effect.provide(layer)); }); @@ -655,33 +643,19 @@ describe("legacy gen signing-key integration", () => { // stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase gen signing-key` // auto-confirms and overwrites rather than consuming the piped `n`. The handler // resolves `yes` via `legacyResolveYes`, not the raw --yes flag. - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n", env: { SUPABASE_YES: "1" } }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(1); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); it.live( @@ -695,73 +669,49 @@ describe("legacy gen signing-key integration", () => { // Defensively clear a shell SUPABASE_YES: this test must prove the project-.env source // specifically, not accidentally pass because a prior test in this file left the shell // env set (the sibling shell-env tests above save/restore theirs). - const prev = process.env["SUPABASE_YES"]; - delete process.env["SUPABASE_YES"]; - const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); + const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n", env: {} }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture( writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), ); - yield* Effect.tryPromise(() => + yield* runFixture( writeFile(join(tempRoot.current, "supabase", ".env"), "SUPABASE_YES=true\n"), ); yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; + const parsed = decodeSigningKeys(saved); expect(parsed).toHaveLength(1); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev !== undefined) process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }, ); it.live("an explicit --yes=false overrides SUPABASE_YES and honors a piped 'n'", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n", cliArgs: ["gen", "signing-key", "--yes=false"], + env: { SUPABASE_YES: "1" }, }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n"), - ); + yield* runFixture(writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n')); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "[]\n")); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyGenSigningKeyCancelledError"); } - const saved = yield* Effect.tryPromise(() => + const saved = yield* runFixture( readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); - expect(JSON.parse(saved)).toEqual([]); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + expect(decodeSigningKeys(saved)).toEqual([]); + }).pipe(Effect.provide(layer)); }); it.live("flushes telemetry state after the command finishes", () => { @@ -780,17 +730,13 @@ describe("legacy gen signing-key integration", () => { // block for the same reason — locks in that fix. const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(tempRoot.current, "supabase"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile(join(tempRoot.current, "supabase", ".env"), "!=broken\n"), - ); + yield* runFixture(mkdir(join(tempRoot.current, "supabase"), { recursive: true })); + yield* runFixture(writeFile(join(tempRoot.current, "supabase", ".env"), "!=broken\n")); const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: false })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDbConfigLoadError"); } expect(telemetry?.flushed).toBe(true); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts index 99e7ceacf9..5ad977b058 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/process-env -- legacy e2e harness owns subprocesses, wall-clock polling, and process environment setup. import { spawn } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -20,7 +21,7 @@ import { resolvePgmetaImage } from "./types.shared.ts"; const TYPEGEN_LANGS = ["typescript", "go", "swift", "python"] as const; type TypegenLang = (typeof TYPEGEN_LANGS)[number]; -const LOCAL_POSTGRES_IMAGE = legacyGetRegistryImageUrl(dockerfileServiceImage("pg")); +const LOCAL_POSTGRES_IMAGE = legacyGetRegistryImageUrl(dockerfileServiceImage("pg"), {}); const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; const TYPEGEN_TIMEOUT_MS = 90_000; // Image resolution happens inside the test bodies, ahead of the startup and diff --git a/apps/cli/src/legacy/commands/gen/types/types.errors.ts b/apps/cli/src/legacy/commands/gen/types/types.errors.ts index 1285e3da84..39f881ded6 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -48,3 +48,12 @@ export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( return actionability.provideFlags; } } + +/** Expected command validation/runtime failure with a stable, typed error channel. */ +export class LegacyGenTypesCommandError extends Data.TaggedError("LegacyGenTypesCommandError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 1bab0dda3d..261fa8a118 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,6 +1,16 @@ import { loadProjectConfig } from "@supabase/config"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; +import { + Config, + Effect, + FileSystem, + Option, + Path, + PlatformError, + Scope, + Stdio, + Stream, +} from "effect"; import { LegacyDnsResolverFlag, LegacyNetworkIdFlag, @@ -13,18 +23,25 @@ import { pflagArgvScan, } from "../../../../shared/cli/cobra-flag-groups.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; -import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; +import { + LegacyInvalidProjectRefError, + LegacyProjectNotLinkedError, +} from "../../../config/legacy-project-ref.errors.ts"; import { LegacyProjectRefResolver, PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/legacy-project-ref.service.ts"; -import { spawnContainerCli } from "../../../shared/legacy-container-cli.ts"; +import { + LegacyContainerRuntimeNotFoundError, + spawnContainerCli, +} from "../../../shared/legacy-container-cli.ts"; import { legacyIsIPv6ConnectivityError, legacyIsIPv6ConnectivityErrorCause, } from "../../../shared/legacy-connect-errors.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { LegacyDbConfigError } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyDbConfigFlags } from "../../../shared/legacy-db-config.types.ts"; import { legacyPoolerConfigFromConnectionString } from "../../../shared/legacy-db-config.parse.ts"; import { @@ -36,20 +53,29 @@ import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; import { legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; -import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; +import type { LegacyProjectRefReadError } from "../../../shared/legacy-temp-paths.ts"; +import { + LegacyPgDeltaSslProbe, + LegacyPgDeltaSslProbeError, +} from "../../../shared/legacy-pgdelta-ssl-probe.service.ts"; import { legacyIsDirectDbHost, legacyRunWithPoolerFallback, } from "../../../shared/legacy-pooler-fallback.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; -import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; +import { + LegacyGenTypesCommandError, + LegacyGenTypesNetworkError, + LegacyGenTypesUnexpectedStatusError, +} from "./types.errors.ts"; +import { legacyViperEnvBool } from "../../../../shared/legacy/legacy-viper-env.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { defaultSchemas, buildPostgresUrl, localDbContainerId, - localDbPassword, localNetworkId, parseDatabaseUrl, parseQueryTimeoutSeconds, @@ -90,6 +116,14 @@ function isProjectNotFound(cause: unknown) { const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; +type LegacyGenTypesRunPgMetaError = + | Config.ConfigError + | LegacyContainerRuntimeNotFoundError + | LegacyDbConfigError + | LegacyGenTypesCommandError + | LegacyPgDeltaSslProbeError + | PlatformError.PlatformError; + type LegacyGenTypesMutexFlag = | "local" | "linked" @@ -136,8 +170,8 @@ const GEN_TYPES_SCAN_SPEC = { } as const; function forwardByteStream( - stream: Stream.Stream<Uint8Array, unknown>, - write: (text: string) => Effect.Effect<void, unknown>, + stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>, + write: (text: string) => Effect.Effect<void>, ) { const decoder = new TextDecoder(); return Stream.runForEach(stream, (chunk) => write(decoder.decode(chunk, { stream: true }))).pipe( @@ -145,7 +179,7 @@ function forwardByteStream( ); } -function collectByteStream(stream: Stream.Stream<Uint8Array, unknown>) { +function collectByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -235,6 +269,8 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const linkedProjectCache = yield* LegacyLinkedProjectCache; const dbConfig = yield* LegacyDbConfigResolver; const sslProbe = yield* LegacyPgDeltaSslProbe; + const viperEnv = yield* LegacyViperEnv; + const caSkipVerify = yield* legacyViperEnvBool("SUPABASE_CA_SKIP_VERIFY"); // "Set" follows cobra's `pflag.Changed` semantics — whether the flag was // passed at all — not the resulting value: `--linked=false` still counts @@ -334,7 +370,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le .pipe(Effect.catch(mapBranchDatabaseConfigError)); if (branch.db_user === undefined || branch.db_pass === undefined) { - return yield* Effect.fail(new Error("Preview branch database credentials are unavailable")); + return yield* new LegacyGenTypesCommandError({ + message: "Preview branch database credentials are unavailable", + }); } const branchUser = branch.db_user; const branchPassword = branch.db_pass; @@ -384,14 +422,15 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le readonly port: number; readonly probeHost: string; readonly probePort: number; - readonly networkMode: "host" | string; + readonly networkMode: string; readonly includedSchemas: string; + readonly projectEnvValues?: Readonly<Record<string, string>>; readonly postgrestV9Compat: boolean; readonly pgmetaVersionOverride?: string; readonly poolerFallback?: { readonly directHost: string; readonly eligible: boolean; - readonly resolve: Effect.Effect<Option.Option<LegacyPgConnInput>, unknown>; + readonly resolve: Effect.Effect<Option.Option<LegacyPgConnInput>, LegacyDbConfigError>; }; }) => Effect.scoped( @@ -402,7 +441,11 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le readonly port: number; readonly probeHost: string; readonly probePort: number; - }) => + }): Effect.Effect< + { readonly exitCode: number; readonly stderrText: string }, + LegacyGenTypesRunPgMetaError, + Scope.Scope + > => Effect.gen(function* () { yield* output.raw(`Connecting to ${target.host} ${target.port}\n`, "stderr"); @@ -425,7 +468,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le // Emitted to stderr when the probe runs with certificate // verification disabled. Our wire-level SSLRequest probe never // verifies certificates, so honour the same env var here too. - if (process.env["SUPABASE_CA_SKIP_VERIFY"] === "true") { + if (caSkipVerify) { yield* output.raw( "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)\n", "stderr", @@ -446,7 +489,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le "--network", networkMode, ...env.flatMap((entry) => ["--env", entry]), - resolvePgmetaImage(input.pgmetaVersionOverride), + resolvePgmetaImage(input.pgmetaVersionOverride, input.projectEnvValues), "node", "dist/server/server.js", ]; @@ -496,7 +539,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le }); if (result.exitCode !== 0) { - return yield* Effect.fail(new Error(`error running container: exit ${result.exitCode}`)); + return yield* new LegacyGenTypesCommandError({ + message: `error running container: exit ${result.exitCode}`, + }); } }), ); @@ -523,15 +568,16 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (exitCode !== 0) { const message = stderr.trim(); if (message.toLowerCase().includes("no such container")) { - return yield* Effect.fail(new Error("supabase start is not running.")); + return yield* new LegacyGenTypesCommandError({ + message: "supabase start is not running.", + }); } - return yield* Effect.fail( - new Error( + return yield* new LegacyGenTypesCommandError({ + message: message.length > 0 ? `failed to inspect service: ${message}` : "failed to inspect service", - ), - ); + }); } }), ); @@ -546,9 +592,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl)) { // Established error text, including the "must used" typo — do not // "fix" the grammar. - return yield* Effect.fail( - new Error("--postgrest-v9-compat must used together with --db-url"), - ); + return yield* new LegacyGenTypesCommandError({ + message: "--postgrest-v9-compat must used together with --db-url", + }); } const legacyLang = findLegacyPositionalLanguage(rawArgs); if ( @@ -556,7 +602,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le legacyLang.value !== "typescript" && !occurrences.has("lang") ) { - return yield* Effect.fail(new Error("use --lang flag to specify the typegen language")); + return yield* new LegacyGenTypesCommandError({ + message: "use --lang flag to specify the typegen language", + }); } // Cobra's mutual exclusion keys off pflag `Changed` — a flag counts as @@ -577,16 +625,23 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le for (const group of GEN_TYPES_MUTEX_GROUPS) { const set = group.filter((flagName) => changedMutexFlags[flagName]); if (set.length > 1) { - return yield* Effect.fail(new Error(cobraMutuallyExclusiveErrorMessage(group, set))); + return yield* new LegacyGenTypesCommandError({ + message: cobraMutuallyExclusiveErrorMessage(group, set), + }); } } if (flags.local) { const config = yield* legacyReadDbToml(fs, path, cliConfig.workdir); - yield* legacyApplyProjectEnv( - config.projectEnv, - Object.keys(config.projectEnv).filter((key) => key !== "SUPABASE_DB_PASSWORD"), - ); + const projectEnvKeys = [ + ...Object.keys(config.projectEnv).filter((key) => key !== "SUPABASE_DB_PASSWORD"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY", + "PGDELTA_NPM_REGISTRY", + ]; + const effectiveProjectEnv = { + ...config.projectEnv, + ...(yield* legacyApplyProjectEnv(config.projectEnv, projectEnvKeys)), + }; const projectId = Option.getOrElse(config.projectId, () => path.basename(cliConfig.workdir)); const paths = legacyTempPaths(path, cliConfig.workdir); @@ -609,21 +664,26 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le schemas.length > 0 ? schemas : defaultSchemas(config.apiSchemas) ).join(","); yield* assertLocalDbRunning(projectId); + const dbPassword = Option.getOrElse( + yield* viperEnv.get("SUPABASE_DB_PASSWORD"), + () => "postgres", + ); yield* runPgMeta({ url: buildPostgresUrl({ host: "db", port: 5432, user: "postgres", - password: localDbPassword(), + password: dbPassword, database: "postgres", }), host: "db", port: 5432, - probeHost: legacyGetHostname(), + probeHost: yield* legacyGetHostname, probePort: config.port, networkMode: localNetworkId(projectId), includedSchemas, + projectEnvValues: effectiveProjectEnv, postgrestV9Compat: flags.postgrestV9Compat || forcedV9, pgmetaVersionOverride, }); @@ -672,19 +732,23 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le return; } - const resolvedRef = yield* projectRef.resolve(Option.none()).pipe( - Effect.catch((cause) => { - if ( - cause instanceof LegacyProjectNotLinkedError && - cause.message === PROJECT_NOT_LINKED_MESSAGE - ) { - return Effect.fail( - new Error("Must specify one of --local, --linked, --project-id, or --db-url"), - ); - } - return Effect.fail(cause); - }), + const resolveProjectRef: Effect.Effect< + string, + LegacyProjectNotLinkedError | LegacyInvalidProjectRefError | LegacyProjectRefReadError + > = projectRef.resolve(Option.none()); + const resolvedRefEffect = Effect.catchTag( + resolveProjectRef, + "LegacyProjectNotLinkedError", + (cause): Effect.Effect<string, LegacyGenTypesCommandError | LegacyProjectNotLinkedError> => + cause.message === PROJECT_NOT_LINKED_MESSAGE + ? Effect.fail( + new LegacyGenTypesCommandError({ + message: "Must specify one of --local, --linked, --project-id, or --db-url", + }), + ) + : Effect.fail(new LegacyProjectNotLinkedError({ message: cause.message })), ); + const resolvedRef = yield* resolvedRefEffect; const loaded = schemas.length > 0 ? null : yield* loadConfigForRef(resolvedRef); yield* runProjectTypes( resolvedRef, diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index ba4950784c..c202168f79 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -1,10 +1,8 @@ -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { createServer } from "node:net"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; +import { BunServices, BunPath } from "@effect/platform-bun"; +import { NodeSocketServer } from "@effect/platform-node"; import type { + SupabaseApiError, V1CreateLoginRoleOutput, V1GetABranchConfigOutput, V1GetPoolerConfigOutput, @@ -15,7 +13,22 @@ import { CliOutput, Command } from "effect/unstable/cli"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; +import { + ConfigProvider, + Effect, + Exit, + Layer, + Option, + PlatformError, + Random, + Sink, + Stdio, + Stream, +} from "effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Socket from "effect/unstable/socket/Socket"; +import * as SocketServer from "effect/unstable/socket/SocketServer"; import { LEGACY_GLOBAL_FLAGS, LegacyDebugFlag, @@ -44,6 +57,9 @@ import { import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; import type { LegacyPgConnInput } from "../../../shared/legacy-db-connection.service.ts"; @@ -69,25 +85,49 @@ import { resolvePgmetaImage, } from "./types.shared.ts"; -function writeConfig(workdir: string, contents: string) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), contents); -} - -function writeTempFile(workdir: string, name: string, contents: string) { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, name), contents); -} +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const join = (first: string, ...rest: ReadonlyArray<string>) => path.join(first, ...rest); +const makeTempDirectory = (prefix: string) => + join("/tmp", `${prefix}${Math.abs(Effect.runSync(Random.nextInt))}`); -function ensureDefaultConfig(workdir: string) { - const configPath = join(workdir, "supabase", "config.toml"); - if (existsSync(configPath)) { - return; - } - writeConfig(workdir, ['project_id = "demo"', "", "[api]", "schemas = []"].join("\n")); -} +const writeConfig = (workdir: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const supabaseDir = join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(join(supabaseDir, "config.toml"), contents); + }).pipe(Effect.provide(BunServices.layer)); + +const writeTempFile = (workdir: string, name: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(join(tempDir, name), contents); + }).pipe(Effect.provide(BunServices.layer)); + +const removeDirectory = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(workdir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); + +const writeProjectFile = (workdir: string, name: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const supabaseDir = join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(join(supabaseDir, name), contents); + }).pipe(Effect.provide(BunServices.layer)); + +const ensureDefaultConfig = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const configPath = join(workdir, "supabase", "config.toml"); + if (!(yield* fs.exists(configPath))) { + yield* writeConfig(workdir, ['project_id = "demo"', "", "[api]", "schemas = []"].join("\n")); + } + }).pipe(Effect.provide(BunServices.layer)); /** Extracts the `KEY=VALUE` entries passed via `docker run --env <entry>` arguments. */ function dockerEnv(args: ReadonlyArray<string>) { @@ -219,6 +259,7 @@ function setup( readonly childLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>; readonly debug?: boolean; readonly networkId?: Option.Option<string>; + readonly env?: Readonly<Record<string, string>>; readonly onSpawn?: (record: { readonly command: string; readonly args: ReadonlyArray<string>; @@ -227,18 +268,20 @@ function setup( readonly generateTypescriptTypes?: (input: { readonly ref: string; readonly included_schemas?: string; - }) => Effect.Effect<{ readonly types: string }, unknown>; + }) => Effect.Effect<{ readonly types: string }, SupabaseApiError>; readonly getABranchConfig?: (input: { readonly branch_id_or_ref: string; - }) => Effect.Effect<BranchConfig, unknown>; + }) => Effect.Effect<BranchConfig, SupabaseApiError>; readonly getPoolerConfig?: (input: { readonly ref: string; - }) => Effect.Effect<PoolerConfig, unknown>; - readonly getProject?: (input: { readonly ref: string }) => Effect.Effect<Project, unknown>; + }) => Effect.Effect<PoolerConfig, SupabaseApiError>; + readonly getProject?: (input: { + readonly ref: string; + }) => Effect.Effect<Project, SupabaseApiError>; readonly createLoginRole?: (input: { readonly ref: string; readonly read_only: boolean; - }) => Effect.Effect<LoginRole, unknown>; + }) => Effect.Effect<LoginRole, SupabaseApiError>; readonly dbConfigResolve?: ( flags: LegacyDbConfigFlags, ) => Effect.Effect<LegacyResolvedDbConfig, LegacyDbConfigError>; @@ -247,10 +290,7 @@ function setup( readonly sslProbeLayer?: Layer.Layer<LegacyPgDeltaSslProbe>; } = {}, ) { - const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "supabase-gen-types-")); - if (!opts.skipConfig) { - ensureDefaultConfig(workdir); - } + const workdir = opts.workdir ?? makeTempDirectory("supabase-gen-types-"); const out = mockOutput({ format: opts.format ?? "text", interactive: (opts.format ?? "text") === "text", @@ -350,9 +390,11 @@ function setup( }), telemetry: telemetry.layer, linkedProjectCache: linkedProjectCache.layer, + env: opts.env, }); const layer = Layer.mergeAll( + opts.skipConfig ? Layer.empty : Layer.effectDiscard(ensureDefaultConfig(workdir)), runtime, BunServices.layer, opts.childLayer ?? child.layer, @@ -399,24 +441,13 @@ function mockSequentialChildProcessSpawner( const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => - Effect.gen(function* () { + Effect.sync(() => { const cmd = command._tag === "StandardCommand" ? command.command : ""; const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ command: cmd, args }); const step = steps[Math.min(stepIndex, steps.length - 1)]; stepIndex += 1; - const exitDeferred = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); - - yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("10 millis"); - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(step?.exitCode ?? 0), - ); - }), - ); const stdoutBytes = (step?.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); const stderrBytes = (step?.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); @@ -426,7 +457,7 @@ function mockSequentialChildProcessSpawner( stdout: Stream.fromIterable(stdoutBytes), stderr: Stream.fromIterable(stderrBytes), all: Stream.empty, - exitCode: Deferred.await(exitDeferred), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(step?.exitCode ?? 0)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -466,29 +497,16 @@ function mockDockerMissingChildProcessSpawner( spawned.push({ command: cmd, args }); if (cmd === "docker") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "docker not found", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }); } const step = steps[Math.min(stepIndex, steps.length - 1)]; stepIndex += 1; - const exitDeferred = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); - - yield* Effect.forkDetach( - Effect.gen(function* () { - yield* Effect.sleep("10 millis"); - yield* Deferred.succeed( - exitDeferred, - ChildProcessSpawner.ExitCode(step?.exitCode ?? 0), - ); - }), - ); const stdoutBytes = (step?.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); const stderrBytes = (step?.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); @@ -498,7 +516,7 @@ function mockDockerMissingChildProcessSpawner( stdout: Stream.fromIterable(stdoutBytes), stderr: Stream.fromIterable(stderrBytes), all: Stream.empty, - exitCode: Deferred.await(exitDeferred), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(step?.exitCode ?? 0)), isRunning: Effect.succeed(false), stdin: Sink.drain, kill: () => Effect.void, @@ -518,38 +536,36 @@ function mockDockerMissingChildProcessSpawner( }; } -async function withSslProbeServer<T>( - run: (port: number) => Promise<T>, +function withSslProbeServer<A, E extends Error>( + run: (port: number) => Effect.Effect<A, E>, response: "N" | "S" = "N", options: { readonly host?: string; readonly port?: number } = {}, -): Promise<T> { +): Effect.Effect<A, E | SocketServer.SocketServerError | Socket.SocketError> { const host = options.host ?? "127.0.0.1"; const port = options.port ?? 0; - const server = createServer((socket) => { - socket.once("data", () => { - socket.write(Buffer.from(response)); - socket.end(); - }); - }); - - await new Promise<void>((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, () => resolve()); - }); - - const address = server.address(); - if (address === null || typeof address === "string") { - server.close(); - throw new Error("failed to bind ssl probe server"); - } - - try { - return await run(address.port); - } finally { - await new Promise<void>((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } + return Effect.scoped( + Effect.gen(function* () { + const server = yield* SocketServer.SocketServer; + yield* Effect.forkScoped( + server.run((socket) => + Effect.scoped( + Effect.gen(function* () { + const writer = yield* socket.writer; + yield* socket.run(() => + writer(new TextEncoder().encode(response)).pipe( + Effect.andThen(writer(new Socket.CloseEvent())), + ), + ); + }), + ), + ), + ); + if (server.address._tag !== "TcpAddress") { + return yield* Effect.die(new Error("failed to bind ssl probe server")); + } + return yield* run(server.address.port); + }).pipe(Effect.provide(NodeSocketServer.layer({ host, port }))), + ); } const nonTypescriptProjectRefScenarios = [ @@ -575,81 +591,83 @@ describe("legacy gen types", () => { ); it.live("runs tokenless local generation through command wiring", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-command-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0, stdout: ["export type Database = {};"] }, - ]); - const args = [ - "gen", - "types", - "typescript", - "--local", - "--schema", - "public", - "--workdir", - workdir, - ]; - const layer = Layer.mergeAll( - BunServices.layer, - CliOutput.layer(textCliOutputFormatter()), - out.layer, - analytics.layer, - processControlLayer, - processEnvLayer({ SUPABASE_HOME: workdir }), - mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), - mockTty({ stdinIsTty: false, stdoutIsTty: false }), - child.layer, - Stdio.layerTest({ args: Effect.succeed(args) }), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: join(workdir, ".supabase"), - tracesDir: join(workdir, ".supabase", "traces"), - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), - ); + withSslProbeServer((port) => + Effect.gen(function* () { + const workdir = makeTempDirectory("supabase-gen-types-command-local-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0 }, + { exitCode: 0, stdout: ["export type Database = {};"] }, + ]); + const args = [ + "gen", + "types", + "typescript", + "--local", + "--schema", + "public", + "--workdir", + workdir, + ]; + const layer = Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + analytics.layer, + processControlLayer, + processEnvLayer({ SUPABASE_HOME: workdir }), + mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + child.layer, + Layer.succeed(CliArgs, { args }), + Layer.succeed(LegacyGoProxy, { + exec: () => Effect.void, + execCapture: () => Effect.succeed(""), + }), + Stdio.layerTest({ args: Effect.succeed(args) }), + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true })), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: join(workdir, ".supabase"), + tracesDir: join(workdir, ".supabase", "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); - await Effect.runPromise( - Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(args).pipe( - Effect.provide(layer), - ) as Effect.Effect<void>, - ); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(args).pipe( + Effect.provide(layer), + ); - expect(out.stdoutText).toContain("export type Database = {};"); - expect(out.stderrText).not.toContain("Access token not provided"); - expect(child.spawned).toHaveLength(2); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(out.stdoutText).toContain("export type Database = {};"); + expect(out.stderrText).not.toContain("Access token not provided"); + expect(child.spawned).toHaveLength(2); + }), + ), ); it.live("generates typescript types from a project ref", () => { @@ -717,17 +735,16 @@ describe("legacy gen types", () => { it.live( "uses configured api schemas for explicit project-id generation when --schema is unset", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-project-id-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].join("\n"), - ); - const { layer, api } = setup({ - workdir, - projectTypes: "ok", - }); - + const workdir = makeTempDirectory("supabase-gen-types-project-id-"); return Effect.gen(function* () { + yield* writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].join("\n"), + ); + const { layer, api } = setup({ + workdir, + projectTypes: "ok", + }); yield* legacyGenTypes( defaultFlags({ projectId: Option.some(LEGACY_VALID_REF), @@ -745,18 +762,17 @@ describe("legacy gen types", () => { it.live( "uses configured api schemas for resolved linked generation when --schema is unset", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].join("\n"), - ); - const { layer, api } = setup({ - workdir, - projectId: Option.some(LEGACY_VALID_REF), - projectTypes: "ok", - }); - + const workdir = makeTempDirectory("supabase-gen-types-linked-"); return Effect.gen(function* () { + yield* writeConfig( + workdir, + ['project_id = "demo"', "", "[api]", 'schemas = ["auth", "storage"]'].join("\n"), + ); + const { layer, api } = setup({ + workdir, + projectId: Option.some(LEGACY_VALID_REF), + projectTypes: "ok", + }); yield* legacyGenTypes(defaultFlags()).pipe(Effect.provide(layer)); expect(api.requests[0]).toEqual({ @@ -1040,54 +1056,50 @@ describe("legacy gen types", () => { it.live( "forwards --query-timeout and --swift-access-control to pg-meta for implicit linked non-TypeScript generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, dbConfig } = setup({ - args: [ - "gen", - "types", - "--lang", - "go", - "--query-timeout", - "20s", - "--swift-access-control", - "public", - ], - projectId: Option.some(LEGACY_VALID_REF), - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "workdir-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)), - ); - - // Unlike an explicit --linked/--project-id, the implicit fallback never - // sets the "linked"/"project-id" mutex keys, so --query-timeout and - // --swift-access-control clear every guard here and reach pg-meta — the - // SIDE_EFFECTS.md defaults-invariant note is scoped to the explicit - // paths for exactly this reason. - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); - expect(docker.env.has("PG_CONN_TIMEOUT_SECS=20")).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer, dbConfig } = setup({ + args: [ + "gen", + "types", + "--lang", + "go", + "--query-timeout", + "20s", + "--swift-access-control", + "public", + ], + projectId: Option.some(LEGACY_VALID_REF), + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "workdir-password", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); + + yield* legacyGenTypes( + defaultFlags({ lang: "go", queryTimeout: "20s", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); + + // Unlike an explicit --linked/--project-id, the implicit fallback never + // sets the "linked"/"project-id" mutex keys, so --query-timeout and + // --swift-access-control clear every guard here and reach pg-meta — the + // SIDE_EFFECTS.md defaults-invariant note is scoped to the explicit + // paths for exactly this reason. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); + expect(docker.env.has("PG_CONN_TIMEOUT_SECS=20")).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); + }), + ), ); it.live("prefers the --postgrest-v9-compat guard over mutex group errors", () => { @@ -1165,393 +1177,424 @@ describe("legacy gen types", () => { }); it.live("allows --swift-access-control for local non-Swift generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-swift-flag-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-swift-flag-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); - const { layer } = setup({ - workdir, - args: [ - "gen", - "types", - "--local", - "--lang", - "python", - "--swift-access-control", - "public", - ], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + const { layer } = setup({ + workdir, + args: ["gen", "types", "--local", "--lang", "python", "--swift-access-control", "public"], + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - // Go has no "--swift-access-control requires --lang swift" guard — - // the value is always forwarded to pg-meta regardless of language. - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), - ).pipe(Effect.provide(layer)), - ); + // Go has no "--swift-access-control requires --lang swift" guard — + // the value is always forwarded to pg-meta regardless of language. + yield* legacyGenTypes( + defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), + ).pipe(Effect.provide(layer)); - expect(docker.env.has("PG_META_GENERATE_TYPES=python")).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES=python")).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL=public")).toBe(true); + }), + ), ); it.live("allows --postgrest-v9-compat together with --db-url", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - args: [ - "gen", - "types", - "--db-url", - `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, - "--postgrest-v9-compat", - ], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer } = setup({ + args: [ + "gen", + "types", + "--db-url", + `postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + "--postgrest-v9-compat", + ], + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - postgrestV9Compat: true, - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + postgrestV9Compat: true, + }), + ).pipe(Effect.provide(layer)); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false")).toBe( + true, + ); + }), + ), ); for (const scenario of nonTypescriptProjectRefScenarios) { it.live(`generates ${scenario.lang} types from a project ref through the DB resolver`, () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, out, child, api, linkedProjectCache, dbConfig } = setup({ - args: ["gen", "types", "--lang", scenario.lang, "--project-id", LEGACY_VALID_REF], - childStdout: [scenario.stdout], - dbConfigResolve: (input) => - Effect.succeed( - remoteResolvedConfig( - { - host: "127.0.0.1", - port, - user: `cli_login_${LEGACY_VALID_REF}`, - password: "temporary-password", - database: "postgres", - }, - (input.linkedProjectRef !== undefined - ? Option.getOrUndefined(input.linkedProjectRef) - : undefined) ?? LEGACY_VALID_REF, - ), - ), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.fail(new Error(`unexpected preview branch lookup for ${branch_id_or_ref}`)), - getProject: ({ ref }) => - Effect.succeed({ - id: ref, - ref, - organization_id: "org-id", - organization_slug: "org", - name: "demo", - region: "us-east-1", - created_at: "2025-01-01T00:00:00Z", - status: "ACTIVE_HEALTHY", - database: { - host: `127.0.0.1:${port}`, - version: "15.1", - postgres_engine: "15", - release_channel: "ga", - }, - }), - createLoginRole: ({ ref }) => - Effect.fail(new Error(`unexpected login role creation for ${ref}`)), - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: scenario.lang, - }), - ).pipe(Effect.provide(layer)), - ); - - expect(api.requests).toContainEqual({ - method: "getProject", - input: { ref: LEGACY_VALID_REF }, - }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "getABranchConfig" }), - ); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "generateTypescriptTypes" }), - ); - expect(child.spawned[0]?.args).toContain("--network"); - expect(child.spawned[0]?.args).toContain("host"); - expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://cli_login_${LEGACY_VALID_REF}:temporary-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --project-id is an ad-hoc remote ref: the resolver must not inherit - // the workdir's ambient password / saved pooler URL. - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); - const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; - expect( - linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, - ).toBe(LEGACY_VALID_REF); - expect(docker.env.has(`PG_META_GENERATE_TYPES=${scenario.lang}`)).toBe(true); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); - expect(out.stdoutText).toContain(scenario.stdout); - expect(linkedProjectCache.cached).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - } - - it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { + withSslProbeServer((port) => + Effect.gen(function* () { const docker = captureDockerRun(); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--linked"], - projectId: Option.some(LEGACY_VALID_REF), - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => + const { layer, out, child, api, linkedProjectCache, dbConfig } = setup({ + args: ["gen", "types", "--lang", scenario.lang, "--project-id", LEGACY_VALID_REF], + childStdout: [scenario.stdout], + dbConfigResolve: (input) => Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "workdir-password", - database: "postgres", - }), + remoteResolvedConfig( + { + host: "127.0.0.1", + port, + user: `cli_login_${LEGACY_VALID_REF}`, + password: "temporary-password", + database: "postgres", + }, + (input.linkedProjectRef !== undefined + ? Option.getOrUndefined(input.linkedProjectRef) + : undefined) ?? LEGACY_VALID_REF, + ), + ), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.fail( + statusApiError( + 500, + `{"message":"unexpected preview branch lookup for ${branch_id_or_ref}"}`, + ), + ), + getProject: ({ ref }) => + Effect.succeed({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `127.0.0.1:${port}`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + }), + createLoginRole: ({ ref }) => + Effect.fail( + statusApiError(500, `{"message":"unexpected login role creation for ${ref}"}`), ), onSpawn: docker.onSpawn, }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe(Effect.provide(layer)), - ); - - expect(dbConfig.resolves).toHaveLength(1); - expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --linked is the workdir's own project: keep workdir-scoped credentials. - expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: scenario.lang, + }), + ).pipe(Effect.provide(layer)); - it.live("preserves resolver URL options for remote non-TypeScript typegen", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childStdout: ["type PublicMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - options: `reference=${LEGACY_VALID_REF}`, - }), - ), - onSpawn: docker.onSpawn, + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), ); - + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "getABranchConfig" }), + ); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "generateTypescriptTypes" }), + ); + expect(child.spawned[0]?.args).toContain("--network"); + expect(child.spawned[0]?.args).toContain("host"); + expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); expect( docker.env.has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${LEGACY_VALID_REF}`, + `PG_META_DB_URL=postgresql://cli_login_${LEGACY_VALID_REF}:temporary-password@127.0.0.1:${port}/postgres?connect_timeout=10`, ), ).toBe(true); + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --project-id is an ad-hoc remote ref: the resolver must not inherit + // the workdir's ambient password / saved pooler URL. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); + const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; + expect( + linkedProjectRef !== undefined ? Option.getOrUndefined(linkedProjectRef) : undefined, + ).toBe(LEGACY_VALID_REF); + expect(docker.env.has(`PG_META_GENERATE_TYPES=${scenario.lang}`)).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); + expect(out.stdoutText).toContain(scenario.stdout); + expect(linkedProjectCache.cached).toBe(true); }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + ), + ); + } - it.live("retries remote pg-meta through the IPv4 pooler on a container IPv6 failure", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', - ], - }, - { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, - ]); - const poolerConn: LegacyPgConnInput = { - host: "127.0.0.1", - port, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }; - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some(poolerConn), - }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", + it.live("resolves the linked workdir DB without ad-hoc project-ref semantics", () => + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--linked"], + projectId: Option.some(LEGACY_VALID_REF), + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "workdir-password", + database: "postgres", }), - ).pipe(Effect.provide(layer)), - ); + ), + onSpawn: docker.onSpawn, + }); - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(out.stderrText).toContain("does not support IPv6"); - expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); - expect(child.spawned).toHaveLength(2); - expect( - dockerEnv(child.spawned[0]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres:direct-password@db.${LEGACY_VALID_REF}.supabase.co:${port}/postgres?connect_timeout=10`, + yield* legacyGenTypes(defaultFlags({ linked: true, lang: "go" })).pipe( + Effect.provide(layer), + ); + + expect(dbConfig.resolves).toHaveLength(1); + expect(dbConfig.resolves[0]?.connType).toBe("linked"); + // --linked is the workdir's own project: keep workdir-scoped credentials. + expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); + }), + ), + ); + + it.live("preserves resolver URL options for remote non-TypeScript typegen", () => + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childStdout: ["type PublicMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + options: `reference=${LEGACY_VALID_REF}`, + }), ), - ).toBe(true); - expect( - dockerEnv(child.spawned[1]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + onSpawn: docker.onSpawn, + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10&options=reference%3D${LEGACY_VALID_REF}`, + ), + ).toBe(true); + }), + ), + ); + + it.live("retries remote pg-meta through the IPv4 pooler on a container IPv6 failure", () => + withSslProbeServer((port) => + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', + ], + }, + { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, + ]); + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), ), - ).toBe(true); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); - expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + poolerFallback: Option.some(poolerConn), + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(out.stderrText).toContain("does not support IPv6"); + expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); + expect(child.spawned).toHaveLength(2); + expect( + dockerEnv(child.spawned[0]?.args ?? []).has( + `PG_META_DB_URL=postgresql://postgres:direct-password@db.${LEGACY_VALID_REF}.supabase.co:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + expect( + dockerEnv(child.spawned[1]?.args ?? []).has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:pooler-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + expect(dbConfig.poolerFallbacks[0]?.connType).toBe("linked"); + expect(dbConfig.poolerFallbacks[0]?.adHocProjectRef).toBe(true); + }), + ), ); it.live("retries remote pg-meta through the IPv4 pooler on Node ENETUNREACH stderr", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: ["connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)"], - }, - { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, - ]); - const poolerConn: LegacyPgConnInput = { + withSslProbeServer((port) => + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: ["connect ENETUNREACH 2600:1f18::1:5432 - Local (:::0)"], + }, + { exitCode: 0, stdout: ["type RetriedViaPooler struct {}"] }, + ]); + const poolerConn: LegacyPgConnInput = { + host: "127.0.0.1", + port, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }; + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some(poolerConn), + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + + expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); + expect(child.spawned).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); + }), + ), + ); + + it.live("does not retry remote pg-meta when the container failure is not IPv6", () => + withSslProbeServer((port) => + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { exitCode: 1, stderr: ["permission denied for schema public"] }, + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ host: "127.0.0.1", port, user: `postgres.${LEGACY_VALID_REF}`, password: "pooler-password", database: "postgres", - }; - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some(poolerConn), - }); + }), + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(out.stdoutText).toContain("type RetriedViaPooler struct {}"); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + }), + ), ); - it.live("does not retry remote pg-meta when the container failure is not IPv6", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { + it.live( + "does not run pooler fallback a second time when the retry also exits with IPv6 stderr", + () => + withSslProbeServer((port) => + Effect.gen(function* () { const child = mockSequentialChildProcessSpawner([ - { exitCode: 1, stderr: ["permission denied for schema public"] }, + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, ]); const { layer, dbConfig } = setup({ args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], @@ -1579,697 +1622,577 @@ describe("legacy gen types", () => { }), }); - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(child.spawned).toHaveLength(2); + expect(dbConfig.poolerFallbacks).toHaveLength(1); }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + ), ); it.live( - "does not run pooler fallback a second time when the retry also exits with IPv6 stderr", + "does not retry remote pg-meta when the resolved connection is already a pooler host", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "127.0.0.1", - port, + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, user: `postgres.${LEGACY_VALID_REF}`, password: "pooler-password", database: "postgres", }), - }); - - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(2); - expect(dbConfig.poolerFallbacks).toHaveLength(1); + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(0); + expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); }), ); - it.live( - "does not retry remote pg-meta when the resolved connection is already a pooler host", - () => - Effect.tryPromise({ - try: () => - Effect.runPromise( + it.live("retries remote pg-meta when the TLS probe fails with ENETUNREACH", () => + Effect.gen(function* () { + let probeCalls = 0; + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ["type RetriedAfterProbeFailure struct {}"] }, + ]); + const { layer, out, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); - - const exit = yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - expect(out.stderrText).not.toContain("Retrying via the IPv4 connection pooler."); + probeCalls += 1; + if (probeCalls === 1) { + return yield* new LegacyPgDeltaSslProbeError({ + message: "network is unreachable", + cause: Object.assign(new Error(), { code: "ENETUNREACH" }), + }); + } + return false; + }), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", }), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); - - it.live("retries remote pg-meta when the TLS probe fails with ENETUNREACH", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - let probeCalls = 0; - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0, stdout: ["type RetriedAfterProbeFailure struct {}"] }, - ]); - const { layer, out, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => - Effect.gen(function* () { - probeCalls += 1; - if (probeCalls === 1) { - return yield* Effect.fail( - new LegacyPgDeltaSslProbeError({ - message: "network is unreachable", - cause: Object.assign(new Error(), { code: "ENETUNREACH" }), - }), - ); - } - return false; - }), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", - }), - }); + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)); + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); - expect(out.stdoutText).toContain("type RetriedAfterProbeFailure struct {}"); - expect(probeCalls).toBe(2); - expect(child.spawned).toHaveLength(1); - expect(dbConfig.poolerFallbacks).toHaveLength(1); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + expect(out.stdoutText).toContain("type RetriedAfterProbeFailure struct {}"); + expect(probeCalls).toBe(2); + expect(child.spawned).toHaveLength(1); + expect(dbConfig.poolerFallbacks).toHaveLength(1); }), ); it.live("does not retry remote pg-meta when the TLS probe fails with ECONNREFUSED", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { exitCode: 0, stdout: ["should not spawn"] }, - ]); - const { layer, dbConfig } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => - Effect.fail( - new LegacyPgDeltaSslProbeError({ - message: "connection refused", - cause: Object.assign(new Error(), { code: "ECONNREFUSED" }), - }), - ), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port: 5432, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallback: Option.some({ - host: "aws-0-us-east-1.pooler.supabase.com", - port: 5432, - user: `postgres.${LEGACY_VALID_REF}`, - password: "pooler-password", - database: "postgres", + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0, stdout: ["should not spawn"] }, + ]); + const { layer, dbConfig } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => + Effect.fail( + new LegacyPgDeltaSslProbeError({ + message: "connection refused", + cause: Object.assign(new Error(), { code: "ECONNREFUSED" }), }), - }); + ), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port: 5432, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + poolerFallback: Option.some({ + host: "aws-0-us-east-1.pooler.supabase.com", + port: 5432, + user: `postgres.${LEGACY_VALID_REF}`, + password: "pooler-password", + database: "postgres", + }), + }); - const exit = yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - expect(child.spawned).toHaveLength(0); - expect(dbConfig.poolerFallbacks).toHaveLength(0); - }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + expect(Exit.isFailure(exit)).toBe(true); + expect(child.spawned).toHaveLength(0); + expect(dbConfig.poolerFallbacks).toHaveLength(0); }), ); it.live("preserves the original remote pg-meta error when pooler fallback resolution fails", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', - ], - }, - ]); - const { layer } = setup({ - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: `db.${LEGACY_VALID_REF}.supabase.co`, - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - poolerFallbackFails: true, - }); - - const exit = await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", + withSslProbeServer((port) => + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + 'could not translate host name "db.abcdefghijklmnopqrst.supabase.co" to address: No address associated with hostname', + ], + }, + ]); + const { layer } = setup({ + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: `db.${LEGACY_VALID_REF}.supabase.co`, + port, + user: "postgres", + password: "direct-password", + database: "postgres", }), - ).pipe(Effect.provide(layer), Effect.exit), - ); + ), + poolerFallbackFails: true, + }); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("error running container: exit 1"); - expect(String(exit.cause)).not.toContain("pooler fallback failed"); - } - expect(child.spawned).toHaveLength(1); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("error running container: exit 1"); + expect(String(exit.cause)).not.toContain("pooler fallback failed"); + } + expect(child.spawned).toHaveLength(1); + }), + ), ); it.live("uses remote config schemas for explicit project-ref pg-meta typegen", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-remote-config-")); - writeConfig( - workdir, - [ - 'project_id = "base"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[remotes.staging]", - `project_id = "${LEGACY_VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const docker = captureDockerRun(); - const { layer } = setup({ - workdir, - args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], - childStdout: ["type PrivateMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); - - try { - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( - true, - ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), - ); + withSslProbeServer((port) => + Effect.gen(function* () { + const workdir = makeTempDirectory("supabase-gen-types-remote-config-"); + yield* writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const docker = captureDockerRun(); + const { layer } = setup({ + workdir, + args: ["gen", "types", "--lang", "go", "--project-id", LEGACY_VALID_REF], + childStdout: ["type PrivateMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "direct-password", + database: "postgres", + }), + ), + onSpawn: docker.onSpawn, + }); - it.live("uses remote config schemas for linked pg-meta typegen", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-linked-config-")); - writeConfig( - workdir, - [ - 'project_id = "base"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[remotes.staging]", - `project_id = "${LEGACY_VALID_REF}"`, - "", - "[remotes.staging.api]", - 'schemas = ["private"]', - "", - ].join("\n"), - ); - const docker = captureDockerRun(); - const { layer } = setup({ - workdir, - projectId: Option.some(LEGACY_VALID_REF), - args: ["gen", "types", "--lang", "go", "--linked"], - childStdout: ["type PrivateMovies struct {}"], - dbConfigResolve: () => - Effect.succeed( - remoteResolvedConfig({ - host: "127.0.0.1", - port, - user: "postgres", - password: "direct-password", - database: "postgres", - }), - ), - onSpawn: docker.onSpawn, - }); + try { + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "go", + }), + ).pipe(Effect.provide(layer)); + } finally { + yield* removeDirectory(workdir); + } - try { - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - linked: true, - lang: "go", - }), - ).pipe(Effect.provide(layer)), - ); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } - - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe( - true, - ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + }), + ), ); - it.live("falls back to preview branch config for non-TypeScript project refs", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, api, dbConfig } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childStdout: ["class PublicMovies(BaseModel):"], - getProject: () => - Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: "127.0.0.1", - db_port: port, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", + it.live("uses remote config schemas for linked pg-meta typegen", () => + withSslProbeServer((port) => + Effect.gen(function* () { + const workdir = makeTempDirectory("supabase-gen-types-linked-config-"); + yield* writeConfig( + workdir, + [ + 'project_id = "base"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[remotes.staging]", + `project_id = "${LEGACY_VALID_REF}"`, + "", + "[remotes.staging.api]", + 'schemas = ["private"]', + "", + ].join("\n"), + ); + const docker = captureDockerRun(); + const { layer } = setup({ + workdir, + projectId: Option.some(LEGACY_VALID_REF), + args: ["gen", "types", "--lang", "go", "--linked"], + childStdout: ["type PrivateMovies struct {}"], + dbConfigResolve: () => + Effect.succeed( + remoteResolvedConfig({ + host: "127.0.0.1", + port, + user: "postgres", + password: "direct-password", + database: "postgres", }), - createLoginRole: ({ ref }) => - Effect.fail(new Error(`unexpected login role creation for ${ref}`)), - onSpawn: docker.onSpawn, - }); + ), + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + try { + yield* legacyGenTypes( + defaultFlags({ + linked: true, + lang: "go", + }), + ).pipe(Effect.provide(layer)); + } finally { + yield* removeDirectory(workdir); + } + + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,private")).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(false); + }), + ), + ); - expect(api.requests).toContainEqual({ - method: "getProject", - input: { ref: LEGACY_VALID_REF }, - }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: LEGACY_VALID_REF }, - }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(dbConfig.resolves).toHaveLength(0); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + it.live("falls back to preview branch config for non-TypeScript project refs", () => + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer, api, dbConfig } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + getProject: () => + Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: port, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + createLoginRole: ({ ref }) => + Effect.fail( + statusApiError(500, `{"message":"unexpected login role creation for ${ref}"}`), ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + onSpawn: docker.onSpawn, + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: LEGACY_VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + ), ); it.live("retries preview branch pg-meta through the branch IPv4 pooler", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - { exitCode: 0, stdout: ["class RetriedViaBranchPooler(BaseModel):"] }, - ]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: `db.${branch_id_or_ref}.supabase.co`, - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - getPoolerConfig: ({ ref }) => - Effect.succeed([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - }); - - yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)); - - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: LEGACY_VALID_REF }, - }); - expect(child.spawned).toHaveLength(2); - expect( - dockerEnv(child.spawned[1]?.args ?? []).has( - `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:branch-password@${poolerHost}:5432/postgres?connect_timeout=10`, - ), - ).toBe(true); + Effect.gen(function* () { + const poolerHost = "aws-0-us-east-1.pooler.supabase.com"; + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + { exitCode: 0, stdout: ["class RetriedViaBranchPooler(BaseModel):"] }, + ]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${branch_id_or_ref}.supabase.co`, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", }), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); + + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); + + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: LEGACY_VALID_REF }, + }); + expect(child.spawned).toHaveLength(2); + expect( + dockerEnv(child.spawned[1]?.args ?? []).has( + `PG_META_DB_URL=postgresql://postgres.${LEGACY_VALID_REF}:branch-password@${poolerHost}:5432/postgres?connect_timeout=10`, ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + ).toBe(true); }), ); it.live("skips preview branch pooler fallback when the pooler URL fails validation", () => - Effect.tryPromise({ - try: () => - Effect.runPromise( - Effect.gen(function* () { - const child = mockSequentialChildProcessSpawner([ - { - exitCode: 1, - stderr: [ - `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, - ], - }, - ]); - const { layer, api } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childLayer: child.layer, - sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { - requireSsl: () => Effect.succeed(false), - requireSslForHost: () => Effect.succeed(false), - }), - getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: `db.${branch_id_or_ref}.supabase.co`, - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - getPoolerConfig: ({ ref }) => - Effect.succeed([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - }); - - const exit = yield* legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer), Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - expect(api.requests).toContainEqual({ - method: "getPoolerConfig", - input: { ref: LEGACY_VALID_REF }, - }); - expect(child.spawned).toHaveLength(1); + Effect.gen(function* () { + const child = mockSequentialChildProcessSpawner([ + { + exitCode: 1, + stderr: [ + `could not translate host name "db.${LEGACY_VALID_REF}.supabase.co" to address: No address associated with hostname`, + ], + }, + ]); + const { layer, api } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childLayer: child.layer, + sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { + requireSsl: () => Effect.succeed(false), + requireSslForHost: () => Effect.succeed(false), + }), + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${branch_id_or_ref}.supabase.co`, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", }), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + getPoolerConfig: ({ ref }) => + Effect.succeed([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@pooler.example.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + }); + + const exit = yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer), Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(api.requests).toContainEqual({ + method: "getPoolerConfig", + input: { ref: LEGACY_VALID_REF }, + }); + expect(child.spawned).toHaveLength(1); }), ); it.live("falls back to preview branch config for any project 404 body", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, api, dbConfig } = setup({ - args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], - childStdout: ["class PublicMovies(BaseModel):"], - // The Management API's 404 wording is not guaranteed; a generic body - // must still route to the branch config endpoint. - getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: "127.0.0.1", - db_port: port, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer, api, dbConfig } = setup({ + args: ["gen", "types", "--lang", "python", "--project-id", LEGACY_VALID_REF], + childStdout: ["class PublicMovies(BaseModel):"], + // The Management API's 404 wording is not guaranteed; a generic body + // must still route to the branch config endpoint. + getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: "127.0.0.1", + db_port: port, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - projectId: Option.some(LEGACY_VALID_REF), - lang: "python", - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + projectId: Option.some(LEGACY_VALID_REF), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: LEGACY_VALID_REF }, - }); - expect(dbConfig.resolves).toHaveLength(0); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: LEGACY_VALID_REF }, + }); + expect(dbConfig.resolves).toHaveLength(0); + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://branch_user:branch-password@127.0.0.1:${port}/postgres?connect_timeout=10`, + ), + ).toBe(true); + }), + ), ); it.live("fails clearly when preview branch config does not include DB credentials", () => { @@ -2306,7 +2229,15 @@ describe("legacy gen types", () => { it.live("maps project type generation network failures", () => { const { layer } = setup({ - generateTypescriptTypes: () => Effect.fail(new Error("network error")), + generateTypescriptTypes: () => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.get("https://api.supabase.test/v1/projects/ref/types"), + description: "Error: network error", + }), + }), + ), }); return Effect.gen(function* () { @@ -2326,333 +2257,294 @@ describe("legacy gen types", () => { }); it.live("spawns pg-meta for local generation and forwards child output", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - "port = 54321", - 'schemas = ["public", "custom"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + "port = 54321", + 'schemas = ["public", "custom"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); - const { layer, out, child, linkedProjectCache } = setup({ - workdir, - childStdout: ["export type Database = {};"], - childStderr: ["pg-meta warning"], - onSpawn: docker.onSpawn, - }); + const { layer, out, child, linkedProjectCache } = setup({ + workdir, + childStdout: ["export type Database = {};"], + childStderr: ["pg-meta warning"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain("Connecting to db 5432"); - expect(out.stderrText).toContain("pg-meta warning"); - expect(out.stdoutText).toContain("export type Database = {};"); - expect(child.spawned).toHaveLength(2); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[1]?.command).toBe("docker"); - expect(child.spawned[1]?.args).toContain("--network"); - expect(child.spawned[1]?.args).toContain("supabase_network_demo"); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,custom")).toBe( - true, - ); - expect(child.spawned[1]?.args).toContain(resolvePgmetaImage()); - // The local/db-url paths have no project ref, so they must not - // populate the linked-project cache. - expect(linkedProjectCache.cached).toBe(false); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(out.stderrText).toContain("Connecting to db 5432"); + expect(out.stderrText).toContain("pg-meta warning"); + expect(out.stdoutText).toContain("export type Database = {};"); + expect(child.spawned).toHaveLength(2); + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo"], + }); + expect(child.spawned[1]?.command).toBe("docker"); + expect(child.spawned[1]?.args).toContain("--network"); + expect(child.spawned[1]?.args).toContain("supabase_network_demo"); + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public,custom")).toBe(true); + expect(child.spawned[1]?.args).toContain(resolvePgmetaImage()); + // The local/db-url paths have no project ref, so they must not + // populate the linked-project cache. + expect(linkedProjectCache.cached).toBe(false); + }), + ), ); it.live("falls back to podman when the docker executable is missing for local generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const child = mockDockerMissingChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 0, stdout: ["export type Database = {};"] }, - ]); - const { layer, out } = setup({ - workdir, - childLayer: child.layer, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const workdir = makeTempDirectory("supabase-gen-types-local-podman-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + const child = mockDockerMissingChildProcessSpawner([ + { exitCode: 0 }, + { exitCode: 0, stdout: ["export type Database = {};"] }, + ]); + const { layer, out } = setup({ + workdir, + childLayer: child.layer, + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect(out.stdoutText).toContain("export type Database = {};"); - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[1]).toEqual({ - command: "podman", - args: ["container", "inspect", "supabase_db_demo"], - }); - expect(child.spawned[2]?.command).toBe("docker"); - expect(child.spawned[2]?.args).toContain("run"); - expect(child.spawned[3]?.command).toBe("podman"); - expect(child.spawned[3]?.args).toContain("run"); - expect(child.spawned[3]?.args).toContain("supabase_network_demo"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(out.stdoutText).toContain("export type Database = {};"); + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo"], + }); + expect(child.spawned[1]).toEqual({ + command: "podman", + args: ["container", "inspect", "supabase_db_demo"], + }); + expect(child.spawned[2]?.command).toBe("docker"); + expect(child.spawned[2]?.args).toContain("run"); + expect(child.spawned[3]?.command).toBe("podman"); + expect(child.spawned[3]?.args).toContain("run"); + expect(child.spawned[3]?.args).toContain("supabase_network_demo"); + }), + ), ); it.live("uses sanitized local docker ids and env-backed local db passwords", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-sanitized-")); - writeConfig( - workdir, - [ - 'project_id = "..demo project with spaces"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-sanitized-"); + yield* writeConfig( + workdir, + [ + 'project_id = "..demo project with spaces"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; - process.env["SUPABASE_DB_PASSWORD"] = "secret-password"; - try { - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); - - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); - - expect(child.spawned[0]).toEqual({ - command: "docker", - args: ["container", "inspect", "supabase_db_demo_project_with_spaces"], - }); - expect(child.spawned[1]?.args).toContain("supabase_network_demo_project_with_spaces"); - expect( - docker.env.has( - "PG_META_DB_URL=postgresql://postgres:secret-password@db:5432/postgres?connect_timeout=10", - ), - ).toBe(true); - } finally { - if (previousPassword === undefined) { - delete process.env["SUPABASE_DB_PASSWORD"]; - } else { - process.env["SUPABASE_DB_PASSWORD"] = previousPassword; - } - } - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + const { layer, child } = setup({ + workdir, + env: { SUPABASE_DB_PASSWORD: "secret-password" }, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); + + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(child.spawned[0]).toEqual({ + command: "docker", + args: ["container", "inspect", "supabase_db_demo_project_with_spaces"], + }); + expect(child.spawned[1]?.args).toContain("supabase_network_demo_project_with_spaces"); + expect( + docker.env.has( + "PG_META_DB_URL=postgresql://postgres:secret-password@db:5432/postgres?connect_timeout=10", + ), + ).toBe(true); + }), + ), ); it.live("forces v9 compat when rest-version reports v9 on a modern database", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-v9-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "major_version = 15", - `port = ${port}`, - ].join("\n"), - ); - writeTempFile(workdir, "rest-version", "v9.0.1\n"); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-v9-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "major_version = 15", + `port = ${port}`, + ].join("\n"), + ); + yield* writeTempFile(workdir, "rest-version", "v9.0.1\n"); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + const { layer } = setup({ + workdir, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false")).toBe( + true, + ); + }), + ), ); it.live("ignores rest-version v9 marker on databases older than 15", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pg14-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "major_version = 14", - `port = ${port}`, - ].join("\n"), - ); - writeTempFile(workdir, "rest-version", "v9.0.1\n"); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-pg14-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "major_version = 14", + `port = ${port}`, + ].join("\n"), + ); + yield* writeTempFile(workdir, "rest-version", "v9.0.1\n"); - const { layer } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + const { layer } = setup({ + workdir, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=true"), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=true")).toBe( + true, + ); + }), + ), ); it.live("overrides the pg-meta image version from the pgmeta-version temp file", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-pgmeta-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - writeTempFile(workdir, "pgmeta-version", "v0.99.0\n"); - - const { layer, child } = setup({ - workdir, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-pgmeta-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + yield* writeTempFile(workdir, "pgmeta-version", "v0.99.0\n"); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + const { layer, child } = setup({ + workdir, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - expect(child.spawned[1]?.args).toContain(resolvePgmetaImage("0.99.0")); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); + + expect(child.spawned[1]?.args).toContain(resolvePgmetaImage("0.99.0")); + }), + ), ); it.live("prefers explicit --schema over config schemas for local generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-schema-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public", "custom"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const { layer } = setup({ workdir, childStdout: ["generated"], onSpawn: docker.onSpawn }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-local-schema-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public", "custom"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + const { layer } = setup({ + workdir, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( - Effect.provide(layer), - ), - ); + yield* legacyGenTypes(defaultFlags({ local: true, schema: ["auth", "storage"] })).pipe( + Effect.provide(layer), + ); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=auth,storage")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=auth,storage")).toBe(true); + }), + ), ); it.live("falls back to the workdir basename when config has no project_id", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-noid-")); - writeConfig( - workdir, - ["[api]", 'schemas = ["public"]', "", "[db]", `port = ${port}`].join("\n"), - ); - const { layer, child } = setup({ workdir, childStdout: ["generated"] }); + withSslProbeServer((port) => + Effect.gen(function* () { + const workdir = makeTempDirectory("supabase-gen-types-local-noid-"); + yield* writeConfig( + workdir, + ["[api]", 'schemas = ["public"]', "", "[db]", `port = ${port}`].join("\n"), + ); + const { layer, child } = setup({ workdir, childStdout: ["generated"] }); - await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - const inspectId = child.spawned[0]?.args[2] ?? ""; - expect(inspectId.startsWith("supabase_db_")).toBe(true); - expect(inspectId).not.toBe("supabase_db_demo"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + const inspectId = child.spawned[0]?.args[2] ?? ""; + expect(inspectId.startsWith("supabase_db_")).toBe(true); + expect(inspectId).not.toBe("supabase_db_demo"); + }), + ), ); it.live("generates from --project-id without a local project config", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-pid-no-config-")); + const workdir = makeTempDirectory("supabase-gen-types-pid-no-config-"); const { layer, api } = setup({ workdir, skipConfig: true, projectTypes: "ok" }); return Effect.gen(function* () { @@ -2668,7 +2560,7 @@ describe("legacy gen types", () => { }); it.live("resolves the linked fallback without a local project config", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-fallback-no-config-")); + const workdir = makeTempDirectory("supabase-gen-types-fallback-no-config-"); const { layer, api } = setup({ workdir, skipConfig: true, @@ -2785,20 +2677,25 @@ describe("legacy gen types", () => { }); it.live("fails with not-running parity when the local db container is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-missing-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const { layer } = setup({ - workdir, - childExitCode: 1, - childStderr: ["Error: No such container: supabase_db_demo"], - }); - + const workdir = makeTempDirectory("supabase-gen-types-local-missing-"); return Effect.gen(function* () { + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer } = setup({ + workdir, + childExitCode: 1, + childStderr: ["Error: No such container: supabase_db_demo"], + }); const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( Effect.provide(layer), Effect.exit, @@ -2812,25 +2709,30 @@ describe("legacy gen types", () => { }); it.live("keeps not-running parity when podman reports the local db container is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-podman-missing-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const child = mockDockerMissingChildProcessSpawner([ - { - exitCode: 1, - stderr: ['Error: inspecting object: no such container "supabase_db_demo"'], - }, - ]); - const { layer } = setup({ - workdir, - childLayer: child.layer, - }); - + const workdir = makeTempDirectory("supabase-gen-types-local-podman-missing-"); return Effect.gen(function* () { + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const child = mockDockerMissingChildProcessSpawner([ + { + exitCode: 1, + stderr: ['Error: inspecting object: no such container "supabase_db_demo"'], + }, + ]); + const { layer } = setup({ + workdir, + childLayer: child.layer, + }); const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( Effect.provide(layer), Effect.exit, @@ -2850,26 +2752,25 @@ describe("legacy gen types", () => { it.live( "preserves inspect failure details when local db inspection fails for other reasons", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-inspect-error-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - "port = 54321", - ].join("\n"), - ); - const { layer } = setup({ - workdir, - childExitCode: 1, - childStderr: ["Cannot connect to the Docker daemon"], - }); - + const workdir = makeTempDirectory("supabase-gen-types-local-inspect-error-"); return Effect.gen(function* () { + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer } = setup({ + workdir, + childExitCode: 1, + childStderr: ["Cannot connect to the Docker daemon"], + }); const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( Effect.provide(layer), Effect.exit, @@ -2886,7 +2787,7 @@ describe("legacy gen types", () => { ); it.live("generates locally with Go defaults when supabase/config.toml is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-")); + const workdir = makeTempDirectory("supabase-gen-types-local-no-config-"); const docker = captureDockerRun(); const probes: Array<{ host: string; port: number }> = []; const { layer, out, child } = setup({ @@ -2907,7 +2808,7 @@ describe("legacy gen types", () => { return Effect.gen(function* () { yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); - const projectId = basename(workdir); + const projectId = path.basename(workdir); expect(child.spawned[0]).toEqual({ command: "docker", args: ["container", "inspect", localDbContainerId(projectId)], @@ -2922,26 +2823,13 @@ describe("legacy gen types", () => { }); it.live("honors local dotenv overrides when supabase/config.toml is missing", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-no-config-env-")); - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync( - join(supabaseDir, ".env"), - [ - "SUPABASE_PROJECT_ID=configless-env-project", - "SUPABASE_DB_PORT=55432", - "SUPABASE_DB_PASSWORD=remote-password", - "SUPABASE_API_SCHEMAS=private,graphql_public", - "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", - "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", - "", - ].join("\n"), - ); + const workdir = makeTempDirectory("supabase-gen-types-local-no-config-env-"); const docker = captureDockerRun(); const probes: Array<{ host: string; port: number }> = []; const { layer, out, child } = setup({ workdir, skipConfig: true, + env: { SUPABASE_SERVICES_HOSTNAME: "host.docker.internal" }, childStdout: ["generated"], onSpawn: docker.onSpawn, sslProbeLayer: Layer.succeed(LegacyPgDeltaSslProbe, { @@ -2955,6 +2843,19 @@ describe("legacy gen types", () => { }); return Effect.gen(function* () { + yield* writeProjectFile( + workdir, + ".env", + [ + "SUPABASE_PROJECT_ID=configless-env-project", + "SUPABASE_DB_PORT=55432", + "SUPABASE_DB_PASSWORD=remote-password", + "SUPABASE_API_SCHEMAS=private,graphql_public", + "SUPABASE_SERVICES_HOSTNAME=host.docker.internal", + "SUPABASE_INTERNAL_IMAGE_REGISTRY=mirror.example.com", + "", + ].join("\n"), + ); yield* legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer)); expect(child.spawned[0]).toEqual({ @@ -2981,16 +2882,21 @@ describe("legacy gen types", () => { }); it.live("reports a generic inspect failure when docker emits no stderr", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-empty-stderr-")); - writeConfig( - workdir, - ['project_id = "demo"', "", "[api]", 'schemas = ["public"]', "", "[db]", "port = 54321"].join( - "\n", - ), - ); - const { layer } = setup({ workdir, childExitCode: 1 }); - + const workdir = makeTempDirectory("supabase-gen-types-local-empty-stderr-"); return Effect.gen(function* () { + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + "port = 54321", + ].join("\n"), + ); + const { layer } = setup({ workdir, childExitCode: 1 }); const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( Effect.provide(layer), Effect.exit, @@ -3005,140 +2911,129 @@ describe("legacy gen types", () => { }); it.live("defaults schemas to public for a db-url run without a project config", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-dburl-no-config-")); - const { layer } = setup({ - workdir, - skipConfig: true, - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const workdir = makeTempDirectory("supabase-gen-types-dburl-no-config-"); + const { layer } = setup({ + workdir, + skipConfig: true, + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + }), + ).pipe(Effect.provide(layer)); - expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); + }), + ), ); it.live("surfaces pg-meta container failures after local db inspection succeeds", () => { - return Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-local-run-error-")); - writeConfig( - workdir, - [ - 'project_id = "demo"', - "", - "[api]", - 'schemas = ["public"]', - "", - "[db]", - `port = ${port}`, - ].join("\n"), - ); - const sequence = mockSequentialChildProcessSpawner([ - { exitCode: 0 }, - { exitCode: 1, stderr: ["pg-meta failed"] }, - ]); - const { layer } = setup({ - workdir, - childLayer: sequence.layer, - }); + return withSslProbeServer((port) => + Effect.gen(function* () { + const workdir = makeTempDirectory("supabase-gen-types-local-run-error-"); + yield* writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + const sequence = mockSequentialChildProcessSpawner([ + { exitCode: 0 }, + { exitCode: 1, stderr: ["pg-meta failed"] }, + ]); + const { layer } = setup({ + workdir, + childLayer: sequence.layer, + }); - const exit = await Effect.runPromise( - legacyGenTypes(defaultFlags({ local: true })).pipe(Effect.provide(layer), Effect.exit), - ); + const exit = yield* legacyGenTypes(defaultFlags({ local: true })).pipe( + Effect.provide(layer), + Effect.exit, + ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain("error running container: exit 1"); - } - expect(sequence.spawned).toHaveLength(2); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain("error running container: exit 1"); + } + expect(sequence.spawned).toHaveLength(2); + }), + ); }); it.live("spawns pg-meta for db-url generation", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, out, child } = setup({ - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer, out, child } = setup({ + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - lang: "swift", - schema: ["public"], - swiftAccessControl: "public", - postgrestV9Compat: true, - queryTimeout: "20s", - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "swift", + schema: ["public"], + swiftAccessControl: "public", + postgrestV9Compat: true, + queryTimeout: "20s", + }), + ).pipe(Effect.provide(layer)); - expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); - expect(child.spawned[0]?.args).toContain("--network"); - expect(child.spawned[0]?.args).toContain("host"); - expect(docker.env.has("PG_META_GENERATE_TYPES=swift")).toBe(true); - expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); - expect( - docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false"), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(out.stderrText).toContain(`Connecting to 127.0.0.1 ${port}`); + expect(child.spawned[0]?.args).toContain("--network"); + expect(child.spawned[0]?.args).toContain("host"); + expect(docker.env.has("PG_META_GENERATE_TYPES=swift")).toBe(true); + expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); + expect(docker.env.has("PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=false")).toBe( + true, + ); + }), + ), ); it.live("injects the CA bundle env var when the database speaks TLS", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { + withSslProbeServer( + (port) => + Effect.gen(function* () { const docker = captureDockerRun(); const { layer } = setup({ childStdout: ["generated"], onSpawn: docker.onSpawn, }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); - }, "S"), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + }), + "S", + ), ); // The SSL probe does not special-case `--debug`: a successful probe // returns true regardless, so the bundle is passed to pgmeta regardless of // the flag. it.live("passes the CA bundle env var in --debug mode when TLS is supported", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { + withSslProbeServer( + (port) => + Effect.gen(function* () { const docker = captureDockerRun(); const { layer } = setup({ childStdout: ["generated"], @@ -3146,112 +3041,91 @@ describe("legacy gen types", () => { onSpawn: docker.onSpawn, }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); expect(docker.env.startsWith("PG_META_DB_SSL_ROOT_CERT=")).toBe(true); - }, "S"), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + }), + "S", + ), ); it.live("warns on stderr when SUPABASE_CA_SKIP_VERIFY is enabled", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const previous = process.env["SUPABASE_CA_SKIP_VERIFY"]; - process.env["SUPABASE_CA_SKIP_VERIFY"] = "true"; - try { - const { layer, out } = setup({ childStdout: ["generated"] }); - - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); - - expect(out.stderrText).toContain( - "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)", - ); - } finally { - if (previous === undefined) { - delete process.env["SUPABASE_CA_SKIP_VERIFY"]; - } else { - process.env["SUPABASE_CA_SKIP_VERIFY"] = previous; - } - } - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + withSslProbeServer((port) => + Effect.gen(function* () { + const { layer, out } = setup({ + env: { SUPABASE_CA_SKIP_VERIFY: "true" }, + childStdout: ["generated"], + }); + + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain( + "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)", + ); + }), + ), ); it.live("honors the --network-id override for the db-url connection", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer, child } = setup({ - childStdout: ["generated"], - networkId: Option.some("custom-network"), - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer, child } = setup({ + childStdout: ["generated"], + networkId: Option.some("custom-network"), + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); - expect(child.spawned[0]?.args).toContain("custom-network"); - expect(child.spawned[0]?.args).not.toContain("host"); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(child.spawned[0]?.args).toContain("custom-network"); + expect(child.spawned[0]?.args).not.toContain("host"); + }), + ), ); it.live("defaults bare db-url connections to the postgres database", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer } = setup({ + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}`), - lang: "swift", - schema: ["public"], - swiftAccessControl: "public", - postgrestV9Compat: true, - queryTimeout: "20s", - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}`), + lang: "swift", + schema: ["public"], + swiftAccessControl: "public", + postgrestV9Compat: true, + queryTimeout: "20s", + }), + ).pipe(Effect.provide(layer)); - expect( - docker.env.has( - `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, - ), - ).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect( + docker.env.has( + `PG_META_DB_URL=postgresql://postgres:postgres@127.0.0.1:${port}/postgres`, + ), + ).toBe(true); + }), + ), ); it.live("accepts legacy positional typescript without changing behavior", () => { @@ -3261,9 +3135,7 @@ describe("legacy gen types", () => { projectTypes: "ok", }); - return Effect.gen(function* () { - yield* legacyGenTypes(defaultFlags()).pipe(Effect.provide(layer)); - }); + return legacyGenTypes(defaultFlags()).pipe(Effect.provide(layer)); }); it.live("rejects legacy positional non-typescript without an explicit lang flag", () => { @@ -3301,29 +3173,25 @@ describe("legacy gen types", () => { ); it.live("allows legacy positional non-typescript when --lang is explicitly set", () => - Effect.tryPromise({ - try: () => - withSslProbeServer(async (port) => { - const docker = captureDockerRun(); - const { layer } = setup({ - args: ["gen", "types", "go", "--lang", "go"], - childStdout: ["generated"], - onSpawn: docker.onSpawn, - }); + withSslProbeServer((port) => + Effect.gen(function* () { + const docker = captureDockerRun(); + const { layer } = setup({ + args: ["gen", "types", "go", "--lang", "go"], + childStdout: ["generated"], + onSpawn: docker.onSpawn, + }); - await Effect.runPromise( - legacyGenTypes( - defaultFlags({ - dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), - lang: "go", - schema: ["public"], - }), - ).pipe(Effect.provide(layer)), - ); + yield* legacyGenTypes( + defaultFlags({ + dbUrl: Option.some(`postgresql://postgres:postgres@127.0.0.1:${port}/postgres`), + lang: "go", + schema: ["public"], + }), + ).pipe(Effect.provide(layer)); - expect(docker.env.has("PG_META_GENERATE_TYPES=go")).toBe(true); - }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + expect(docker.env.has("PG_META_GENERATE_TYPES=go")).toBe(true); + }), + ), ); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.ts index 7a3970c72d..e3713658e4 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -80,7 +80,11 @@ export const legacyGenTypesRuntimeLayer = (() => { commandRuntimeLayer(["gen", "types"]), ); - const _serviceCoverageCheck: Layer.Layer<LegacyGenTypesServices, unknown, unknown> = built; + const _serviceCoverageCheck: Layer.Layer< + LegacyGenTypesServices, + Layer.Error<typeof built>, + Layer.Services<typeof built> + > = built; void _serviceCoverageCheck; return built; diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts index 66d6948a2b..d256d2e2fd 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.unit.test.ts @@ -42,6 +42,7 @@ import { import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyIdentityStitch } from "../../../shared/legacy-identity-stitch.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyGenTypesRuntimeLayer } from "./types.layers.ts"; @@ -94,6 +95,7 @@ function ambientStubs() { mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, heavyServiceStubs, + makeLegacyViperEnvLayer(), ); } @@ -104,7 +106,7 @@ describe("legacyGenTypesRuntimeLayer — LegacyIdentityStitch exposure", () => { return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(legacyGenTypesRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyGenTypesRuntimeLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); }); diff --git a/apps/cli/src/legacy/commands/gen/types/types.live.test.ts b/apps/cli/src/legacy/commands/gen/types/types.live.test.ts index ac1acc30fe..d856c34f3f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.live.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { expect } from "vitest"; import { test } from "../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/gen/types/types.shared.ts b/apps/cli/src/legacy/commands/gen/types/types.shared.ts index 4480a03ada..8be141c617 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import * as Formatter from "effect/Formatter"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; import { @@ -25,6 +26,9 @@ const DURATION_UNITS_TO_MILLIS = { m: 60_000, h: 3_600_000, } as const; +type DurationUnit = keyof typeof DURATION_UNITS_TO_MILLIS; + +const isDurationUnit = (value: string): value is DurationUnit => value in DURATION_UNITS_TO_MILLIS; const DURATION_PART_PATTERN = new RegExp( String.raw`([+-]?(?:\d+\.?\d*|\.\d+))(ns|us|\u00b5s|\u03bcs|ms|s|m|h)`, @@ -35,7 +39,7 @@ export interface LegacyGenTypesDbTarget { readonly url: string; readonly host: string; readonly port: number; - readonly networkMode: "host" | string; + readonly networkMode: string; } export function defaultSchemas(extraSchemas: ReadonlyArray<string> = []) { @@ -48,11 +52,9 @@ export function parseQueryTimeoutSeconds( return Effect.gen(function* () { const input = raw.trim(); if (input.length === 0) { - return yield* Effect.fail( - new LegacyInvalidGenTypesDurationError({ - message: `invalid duration ${JSON.stringify(raw)}`, - }), - ); + return yield* new LegacyInvalidGenTypesDurationError({ + message: `invalid duration ${Formatter.formatJson(raw)}`, + }); } let totalMillis = 0; @@ -69,34 +71,31 @@ export function parseQueryTimeoutSeconds( continue; } if (match.index !== consumed) { - return yield* Effect.fail( - new LegacyInvalidGenTypesDurationError({ - message: `invalid duration ${JSON.stringify(raw)}`, - }), - ); + return yield* new LegacyInvalidGenTypesDurationError({ + message: `invalid duration ${Formatter.formatJson(raw)}`, + }); } const amount = Number.parseFloat(rawNumber); - const unitMillis = DURATION_UNITS_TO_MILLIS[rawUnit as keyof typeof DURATION_UNITS_TO_MILLIS]; + if (!isDurationUnit(rawUnit)) { + return yield* new LegacyInvalidGenTypesDurationError({ + message: `invalid duration ${Formatter.formatJson(raw)}`, + }); + } + const unitMillis = DURATION_UNITS_TO_MILLIS[rawUnit]; totalMillis += amount * unitMillis; consumed += token.length; } if (!Number.isFinite(totalMillis) || consumed !== input.length || totalMillis < 0) { - return yield* Effect.fail( - new LegacyInvalidGenTypesDurationError({ - message: `invalid duration ${JSON.stringify(raw)}`, - }), - ); + return yield* new LegacyInvalidGenTypesDurationError({ + message: `invalid duration ${Formatter.formatJson(raw)}`, + }); } return Math.round(totalMillis / 1_000); }); } -export function localDbPassword() { - return process.env["SUPABASE_DB_PASSWORD"] ?? "postgres"; -} - export function parseDatabaseUrl( url: string, ): Effect.Effect<LegacyGenTypesDbTarget, LegacyInvalidGenTypesDatabaseUrlError> { @@ -139,13 +138,17 @@ export function buildPostgresUrl(input: { ); } -export function resolvePgmetaImage(versionOverride?: string) { +export function resolvePgmetaImage( + versionOverride?: string, + projectEnvValues?: Readonly<Record<string, string>>, +) { const defaultImage = dockerfileServiceImage("pgmeta"); if (versionOverride === undefined || versionOverride.trim().length === 0) { - return legacyGetRegistryImageUrl(defaultImage); + return legacyGetRegistryImageUrl(defaultImage, projectEnvValues ?? {}); } return legacyGetRegistryImageUrl( replaceImageTag(defaultImage, `v${versionOverride.trim().replace(/^v/i, "")}`), + projectEnvValues ?? {}, ); } diff --git a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts index b0c6c9b797..28c39c53ec 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts @@ -1,37 +1,20 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { ConfigProvider, Effect, Exit, Layer } from "effect"; import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyParseSchemaFlags } from "../../../shared/legacy-schema-flags.ts"; import { buildPostgresUrl, defaultSchemas, legacyRootCaBundle, localDbContainerId, - localDbPassword, localNetworkId, parseDatabaseUrl, parseQueryTimeoutSeconds, resolvePgmetaImage, } from "./types.shared.ts"; -function withEnv<T>(key: string, value: string | undefined, run: () => T): T { - const previous = process.env[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - try { - return run(); - } finally { - if (previous === undefined) { - delete process.env[key]; - } else { - process.env[key] = previous; - } - } -} - describe("parseQueryTimeoutSeconds", () => { it.effect("parses compound Go durations", () => Effect.gen(function* () { @@ -127,57 +110,51 @@ describe("parseDatabaseUrl", () => { describe("resolvePgmetaImage", () => { it("uses the default pgmeta version when no override is given", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage(), - ); + const image = resolvePgmetaImage(undefined, {}); expect(image).toContain("postgres-meta"); }); it("strips a leading v from a version override", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmetaImage("v1.2.3"), - ); + const image = resolvePgmetaImage("v1.2.3", { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", + }); expect(image).toBe("supabase/postgres-meta:v1.2.3"); }); it("falls back to the default when the override is blank", () => { - const withOverride = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmetaImage(" "), - ); - const withoutOverride = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "docker.io", () => - resolvePgmetaImage(), - ); + const withOverride = resolvePgmetaImage(" ", { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", + }); + const withoutOverride = resolvePgmetaImage(undefined, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "docker.io", + }); expect(withOverride).toBe(withoutOverride); }); it("uses the supabase registry for any non docker.io registry", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage("1.2.3"), - ); + const image = resolvePgmetaImage("1.2.3", {}); expect(image).not.toBe("supabase/postgres-meta:v1.2.3"); expect(image).toContain("postgres-meta:v1.2.3"); }); it("defaults to the ECR mirror when no registry override is set", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", undefined, () => - resolvePgmetaImage("1.2.3"), - ); + const image = resolvePgmetaImage("1.2.3", {}); expect(image).toBe("public.ecr.aws/supabase/postgres-meta:v1.2.3"); }); it("honors SUPABASE_INTERNAL_IMAGE_REGISTRY for a non docker.io registry (e.g. ghcr.io)", () => { // Regression: setup-cli exports `ghcr.io` on shared CI runners to dodge ECR // rate limits, but gen types used to ignore it and still pull from ECR. - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "ghcr.io", () => - resolvePgmetaImage("1.2.3"), - ); + const image = resolvePgmetaImage("1.2.3", { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "ghcr.io", + }); expect(image).toBe("ghcr.io/supabase/postgres-meta:v1.2.3"); }); it("rewrites to an arbitrary configured mirror registry", () => { - const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "my.registry.example", () => - resolvePgmetaImage("1.2.3"), - ); + const image = resolvePgmetaImage("1.2.3", { + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.registry.example", + }); expect(image).toBe("my.registry.example/supabase/postgres-meta:v1.2.3"); }); }); @@ -209,17 +186,26 @@ describe("schema and id helpers", () => { expect(localDbContainerId(longId)).toBe(`supabase_db_${"a".repeat(40)}`); }); - it("reads the services hostname and db password from the environment", () => { - expect( - withEnv("DOCKER_HOST", undefined, () => - withEnv("SUPABASE_SERVICES_HOSTNAME", undefined, () => legacyGetHostname()), - ), - ).toBe("127.0.0.1"); - expect(withEnv("SUPABASE_SERVICES_HOSTNAME", "db.internal", () => legacyGetHostname())).toBe( - "db.internal", + it.effect("reads the services hostname and db password from the environment", () => { + const layer = Layer.mergeAll( + BunServices.layer, + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true })), ); - expect(withEnv("SUPABASE_DB_PASSWORD", undefined, () => localDbPassword())).toBe("postgres"); - expect(withEnv("SUPABASE_DB_PASSWORD", "secret", () => localDbPassword())).toBe("secret"); + return Effect.gen(function* () { + expect(yield* legacyGetHostname).toBe("127.0.0.1"); + expect( + yield* legacyGetHostname.pipe( + Effect.provide( + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ + env: { SUPABASE_SERVICES_HOSTNAME: "db.internal" }, + preserveEmptyStrings: true, + }), + ), + ), + ), + ).toBe("db.internal"); + }).pipe(Effect.provide(layer)); }); it("brackets ipv6 hosts in the generated postgres url", () => { diff --git a/apps/cli/src/legacy/commands/init/init.handler.ts b/apps/cli/src/legacy/commands/init/init.handler.ts index f97aff3ffb..31a77867fd 100644 --- a/apps/cli/src/legacy/commands/init/init.handler.ts +++ b/apps/cli/src/legacy/commands/init/init.handler.ts @@ -1,5 +1,4 @@ -import { resolve } from "node:path"; -import { Effect, Option } from "effect"; +import { Effect, Option, Path } from "effect"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { initProject } from "../../../shared/init/project-init.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -20,15 +19,15 @@ export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitF if (flags.useOrioledb && !experimental) { // Go marks `experimental` required in PreRun (`cmd/init.go:32-36`), so cobra's // `ValidateRequiredFlags` fails with its standard required-flag message. - return yield* Effect.fail( - new LegacyInitExperimentalRequiredError({ - message: `required flag(s) "experimental" not set`, - }), - ); + return yield* new LegacyInitExperimentalRequiredError({ + message: `required flag(s) "experimental" not set`, + }); } const result = yield* initProject({ - cwd: Option.isSome(workdir) ? resolve(runtimeInfo.cwd, workdir.value) : runtimeInfo.cwd, + cwd: Option.isSome(workdir) + ? yield* Path.Path.pipe(Effect.map((path) => path.resolve(runtimeInfo.cwd, workdir.value))) + : runtimeInfo.cwd, force: flags.force, useOrioledb: flags.useOrioledb, interactive: flags.interactive, @@ -54,12 +53,10 @@ export const legacyInit = Effect.fn("legacy.init")(function* (flags: LegacyInitF runtimeInfo.platform === "win32" ? "failed to create config file: open supabase\\config.toml: The file exists." : "failed to create config file: open supabase/config.toml: file exists"; - return yield* Effect.fail( - new LegacyInitConfigExistsError({ - message, - suggestion: "Run supabase init --force to overwrite existing config file.", - }), - ); + return yield* new LegacyInitConfigExistsError({ + message, + suggestion: "Run supabase init --force to overwrite existing config file.", + }); } yield* output.raw("Finished supabase init.\n"); diff --git a/apps/cli/src/legacy/commands/init/init.integration.test.ts b/apps/cli/src/legacy/commands/init/init.integration.test.ts index ce74c26f59..8e66bb849b 100644 --- a/apps/cli/src/legacy/commands/init/init.integration.test.ts +++ b/apps/cli/src/legacy/commands/init/init.integration.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Stdio, +} from "effect"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { LegacyExperimentalFlag, @@ -21,10 +27,18 @@ import { mockStdin, mockTty, } from "../../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyInit } from "./init.handler.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-legacy-init-"); -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-legacy-init-")); +function readProjectFile(...segments: string[]) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(...segments)); + }).pipe(Effect.provide(BunServices.layer)); } function setup( @@ -37,10 +51,16 @@ function setup( yes?: boolean; /** Piped stdin lines consumed by the non-TTY IDE-settings confirm reads. */ stdinInput?: string; + env?: Readonly<Record<string, string | undefined>>; platform?: NodeJS.Platform; } = {}, ) { const out = mockOutput({ format: "text", interactive: opts.interactive ?? false }); + const env: Record<string, string> = {}; + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (value !== undefined) env[key] = value; + } + const configProvider = ConfigProvider.fromEnv({ env, preserveEmptyStrings: true }); return { out, layer: Layer.mergeAll( @@ -56,6 +76,8 @@ function setup( Layer.succeed(LegacyWorkdirFlag, opts.workdir ?? Option.none()), Layer.succeed(LegacyYesFlag, opts.yes ?? false), Layer.succeed(CliArgs, { args: [] }), + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), ), }; } @@ -111,7 +133,7 @@ function renderFailureToStderr(exit: Exit.Exit<unknown, unknown>) { describe("legacy init", () => { it.live("creates config.toml natively without the Go proxy", () => { - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; return Effect.gen(function* () { const { layer, out } = setup(tempDir); @@ -125,18 +147,14 @@ describe("legacy init", () => { withIntellijSettings: false, }).pipe(Effect.provide(layer)); - const content = yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "config.toml"), "utf8"), - ); + const content = yield* readProjectFile(tempDir, "supabase", "config.toml"); expect(content).toContain("major_version = 17"); expect(out.stdoutText).toBe("Finished supabase init.\n"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("requires --experimental when --use-orioledb is set, with cobra's exact wording", () => { - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; return Effect.gen(function* () { const { layer } = setup(tempDir, { experimental: false }); @@ -163,13 +181,11 @@ describe("legacy init", () => { `required flag(s) "experimental" not set\n`, "Try rerunning the command with --debug to troubleshoot the error.\n", ]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }); }); it.live("fails with Go's exact error when config.toml already exists", () => { - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; const initFlags = { interactive: false, @@ -202,13 +218,11 @@ describe("legacy init", () => { "failed to create config file: open supabase/config.toml: file exists\n", "Run supabase init --force to overwrite existing config file.\n", ]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }); }); it.live("renders the Windows form of the already-exists error on win32", () => { - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; const initFlags = { interactive: false, @@ -244,13 +258,11 @@ describe("legacy init", () => { "failed to create config file: open supabase\\config.toml: The file exists.\n", "Run supabase init --force to overwrite existing config file.\n", ]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }); }); it.live("supports the hidden IDE flags natively", () => { - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; return Effect.gen(function* () { const { layer, out } = setup(tempDir); @@ -264,29 +276,26 @@ describe("legacy init", () => { withIntellijSettings: true, }).pipe(Effect.provide(layer)); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, ".vscode", "extensions.json"), "utf8"), - ), - ).toContain('"recommendations"'); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), - ).toContain('"deno.enablePaths"'); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".idea", "deno.xml"), "utf8")), - ).toContain('<component name="DenoSettings">'); + expect(yield* readProjectFile(tempDir, ".vscode", "extensions.json")).toContain( + '"recommendations"', + ); + expect(yield* readProjectFile(tempDir, ".vscode", "settings.json")).toContain( + '"deno.enablePaths"', + ); + expect(yield* readProjectFile(tempDir, ".idea", "deno.xml")).toContain( + '<component name="DenoSettings">', + ); expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); expect(out.stdoutText).toContain("Generated IntelliJ settings in .idea/deno.xml."); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }); }); it.live("respects the legacy --workdir global flag", () => { - const tempDir = makeTempDir(); - const workdir = join(tempDir, "nested"); + const tempDir = tempRoot.current; return Effect.gen(function* () { + const path = yield* Path.Path; + const workdir = path.join(tempDir, "nested"); const { layer } = setup(tempDir, { workdir: Option.some("nested") }); yield* legacyInit({ @@ -298,13 +307,9 @@ describe("legacy init", () => { withIntellijSettings: false, }).pipe(Effect.provide(layer)); - const content = yield* Effect.tryPromise(() => - readFile(join(workdir, "supabase", "config.toml"), "utf8"), - ); + const content = yield* readProjectFile(workdir, "supabase", "config.toml"); expect(content).toContain("major_version = 17"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(BunServices.layer)); }); // --------------------------------------------------------------------------- @@ -322,7 +327,7 @@ describe("legacy init", () => { } as const; it.live("init -i --yes writes VS Code settings with the Go echo instead of prompting", () => { - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; return Effect.gen(function* () { const { layer, out } = setup(tempDir, { interactive: true, stdinIsTty: true, yes: true }); @@ -333,44 +338,36 @@ describe("legacy init", () => { expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); // Go returns after writing VS Code settings — IntelliJ is never asked. expect(out.stderrText).not.toContain("IntelliJ"); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), - ).toContain('"deno.enablePaths"'); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* readProjectFile(tempDir, ".vscode", "settings.json")).toContain( + '"deno.enablePaths"', + ); + }); }); it.live("init -i with SUPABASE_YES=1 auto-accepts the VS Code prompt like --yes", () => { - const tempDir = makeTempDir(); - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; + const tempDir = tempRoot.current; return Effect.gen(function* () { - const { layer, out } = setup(tempDir, { interactive: true, stdinIsTty: true }); + const { layer, out } = setup(tempDir, { + interactive: true, + stdinIsTty: true, + env: { SUPABASE_YES: "1" }, + }); yield* legacyInit({ ...BASE_INIT_FLAGS, interactive: true }).pipe(Effect.provide(layer)); expect(out.promptConfirmCalls).toHaveLength(0); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), - ).toContain('"deno.enablePaths"'); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* readProjectFile(tempDir, ".vscode", "settings.json")).toContain( + '"deno.enablePaths"', + ); + }); }); it.live("init -i --yes writes VS Code settings even when stdout is piped (Go parity)", () => { // Go gates the IDE prompts on `-i` + a TTY stdin only (`cmd/init.go:40`); with // YES set no clack UI is rendered, so a piped stdout must not skip the write. - const tempDir = makeTempDir(); + const tempDir = tempRoot.current; return Effect.gen(function* () { const { layer, out } = setup(tempDir, { interactive: false, stdinIsTty: true, yes: true }); @@ -378,11 +375,9 @@ describe("legacy init", () => { yield* legacyInit({ ...BASE_INIT_FLAGS, interactive: true }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), - ).toContain('"deno.enablePaths"'); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* readProjectFile(tempDir, ".vscode", "settings.json")).toContain( + '"deno.enablePaths"', + ); + }); }); }); diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts index c747836609..1b6374402e 100644 --- a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { expect } from "vitest"; import { test } from "../../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts b/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts index 48641850ce..da4e06c3a2 100644 --- a/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- legacy e2e test callbacks are Promise-based at the subprocess boundary. import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts index 41c6a5098e..afad3b06ec 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Formatter, Layer, Option } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { mockLegacyTelemetryStateTracked } from "../../../../../tests/helpers/legacy-mocks.ts"; @@ -414,7 +414,7 @@ describe("legacy inspect db query runner", () => { const exit = yield* Effect.exit(legacyInspectDbDbStats(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("syntax error"); + expect(Formatter.formatJson(exit.cause)).toContain("syntax error"); } }).pipe(Effect.provide(layer)); }); @@ -425,7 +425,7 @@ describe("legacy inspect db query runner", () => { const exit = yield* Effect.exit(legacyInspectDbDbStats(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to connect to postgres"); } }).pipe(Effect.provide(layer)); }); @@ -436,7 +436,7 @@ describe("legacy inspect db query runner", () => { const exit = yield* Effect.exit(legacyInspectDbDbStats(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("cannot load config"); + expect(Formatter.formatJson(exit.cause)).toContain("cannot load config"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts index 44d4fea898..198962b79b 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts @@ -15,6 +15,15 @@ import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +const inspectValueToString = (value: unknown): string => { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; + /** * The connection selector flags every `inspect db` subcommand inherits from the * `inspect` persistent flag set: @@ -88,7 +97,7 @@ export class LegacyInspectMutuallyExclusiveFlagsError extends Data.TaggedError( * few UNWRAPPED columns (no code span) use `legacyInspectPlainText`. */ export function legacyInspectText(value: unknown): string { - const text = value === null || value === undefined ? "" : String(value); + const text = value === null || value === undefined ? "" : inspectValueToString(value); return text === "" ? "``" : text; } @@ -100,14 +109,14 @@ export function legacyInspectText(value: unknown): string { */ export function legacyInspectPlainText(value: unknown): string { if (value === null || value === undefined) return ""; - return String(value); + return inspectValueToString(value); } /** A bool column. The driver maps Postgres `boolean` to a JS boolean. */ export function legacyInspectBool(value: unknown): string { if (typeof value === "boolean") return value ? "true" : "false"; if (value === null || value === undefined) return "false"; - return String(value); + return inspectValueToString(value); } /** @@ -118,7 +127,7 @@ export function legacyInspectBool(value: unknown): string { export function legacyInspectInt(value: unknown): string { if (value === null || value === undefined) return "0"; if (typeof value === "bigint") return value.toString(); - return String(value); + return inspectValueToString(value); } /** A float column: always one decimal place (`12` → `"12.0"`). */ @@ -130,7 +139,7 @@ export function legacyInspectFloat1(value: unknown): string { return Number.isNaN(parsed) ? value : parsed.toFixed(1); } if (value === null || value === undefined) return "0.0"; - return String(value); + return inspectValueToString(value); } /** @@ -147,7 +156,8 @@ export function legacyInspectStmt(value: unknown): string { // with a single space — the exact character set this must match, since // JS's `\s` differs (it includes `\v` AND Unicode spaces like nbsp, U+2028) // and a naive `/\s+/g` would over-collapse runs this must leave alone. - return String(value).replace(/[\t\n\f\r ]+|\v/g, " "); + const text = inspectValueToString(value); + return text.replace(/[\t\n\f\r ]+|\v/g, " "); } /** @@ -186,11 +196,9 @@ export const legacyRunInspectQuery = Effect.fnUntraced(function* ( // it and route to linked incorrectly. const target = resolveLegacyDbTargetFlags(cliArgs.args); if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyInspectMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyInspectMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } // `--linked` is the default, so absence of `--db-url`/`--local` resolves @@ -202,12 +210,10 @@ export const legacyRunInspectQuery = Effect.fnUntraced(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyInspectMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyInspectMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const cfg = yield* resolver.resolve({ diff --git a/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts b/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts index e57a2c3b4f..4e8048a094 100644 --- a/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/inspect/inspect.layers.unit.test.ts @@ -42,6 +42,7 @@ import { import { LegacyDbConfigResolver } from "../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../shared/legacy-db-connection.service.ts"; import { LegacyIdentityStitch } from "../../shared/legacy-identity-stitch.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyInspectBaseLayer } from "./inspect.layers.ts"; @@ -92,6 +93,7 @@ function ambientStubs() { mockLegacyLinkedProjectCacheLayer, mockLegacyTelemetryStateLayer, heavyServiceStubs, + makeLegacyViperEnvLayer(), ); } @@ -102,7 +104,7 @@ describe("legacyInspectBaseLayer — LegacyIdentityStitch exposure", () => { return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(legacyInspectBaseLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyInspectBaseLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); }); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.ts index e05ed9b7ad..a48ece44c6 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.config.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.ts @@ -1,10 +1,12 @@ -import { Effect, type FileSystem, type Path } from "effect"; +import { Effect, Option, type FileSystem, type Path } from "effect"; import * as SmolToml from "smol-toml"; import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import { legacyErrorMessage } from "../../../shared/legacy-error-message.ts"; import { legacyExpandEnv, legacyLoadProjectEnv, } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyViperEnv } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { LegacyInspectRule } from "./report.rules.ts"; type RawDoc = { readonly [key: string]: unknown }; @@ -56,7 +58,7 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( Effect.map((text): string | undefined => text), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" - ? Effect.succeed(undefined) + ? Effect.void : Effect.fail( new LegacyDbConfigLoadError({ message: `failed to read file config: ${error.message}`, @@ -67,16 +69,13 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( if (content === undefined) return [] as ReadonlyArray<LegacyInspectRule>; - let doc: RawDoc | undefined; - try { - doc = asRecord(SmolToml.parse(content)); - } catch (cause) { - return yield* Effect.fail( + const doc = yield* Effect.try({ + try: () => asRecord(SmolToml.parse(content)), + catch: (cause) => new LegacyDbConfigLoadError({ - message: `failed to load config: ${cause instanceof Error ? cause.message : String(cause)}`, + message: `failed to load config: ${legacyErrorMessage(cause)}`, }), - ); - } + }); const inspect = asRecord(asRecord(doc?.["experimental"])?.["inspect"]); const rawRules = inspect?.["rules"]; @@ -107,7 +106,26 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( // Resolve `env(VAR)` against the shell env first, then the project `.env` files. const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); - const lookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; + const viperEnv = yield* LegacyViperEnv; + const expandField = (value: string) => { + const match = /^env\((.*)\)$/u.exec(value); + if (match === null) return Effect.succeed(value); + const name = match[1] ?? ""; + return viperEnv.get(name).pipe( + Effect.map((shellValue) => + legacyExpandEnv( + value, + (requested) => Option.getOrUndefined(shellValue) ?? projectEnv[requested], + ), + ), + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ + message: `failed to load config environment: ${legacyErrorMessage(cause)}`, + }), + ), + ); + }; const rules: Array<LegacyInspectRule> = []; for (let index = 0; index < entries.length; index++) { @@ -116,11 +134,9 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( // it fails to load with "expected a map or struct" rather than being // silently skipped. if (record === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to load config: experimental.inspect.rules[${index}] expected a map or struct`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}] expected a map or struct`, + }); } // An unknown/misspelled key in a rule table (e.g. // `fails = "bad"`) aborts the whole config load — there is no escape hatch. @@ -128,24 +144,20 @@ export const legacyReadInspectRules = Effect.fnUntraced(function* ( (key) => !(RULE_FIELDS as ReadonlyArray<string>).includes(key), ); if (unknownKeys.length > 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to load config: experimental.inspect.rules[${index}] has invalid keys: ${unknownKeys.join(", ")}`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}] has invalid keys: ${unknownKeys.join(", ")}`, + }); } const fields: Record<string, string> = {}; for (const field of RULE_FIELDS) { const coerced = coerceRuleField(record[field]); // A non-coercible field type (nested table/array/datetime) aborts too. if (coerced === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to load config: experimental.inspect.rules[${index}].${field} expected a string`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to load config: experimental.inspect.rules[${index}].${field} expected a string`, + }); } - fields[field] = legacyExpandEnv(coerced, lookup); + fields[field] = yield* expandField(coerced); } rules.push({ query: fields["query"]!, diff --git a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts index 836e4a6683..ca6d45539d 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.config.unit.test.ts @@ -1,39 +1,47 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Path } from "effect"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { ConfigProvider, Effect, FileSystem, Formatter, Layer, Path } from "effect"; +import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; import { legacyReadInspectRules } from "./report.config.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; -function makeWorkdir(configToml?: string): string { - const workdir = mkdtempSync(join(tmpdir(), "supabase-report-config-")); - if (configToml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), configToml); - } - return workdir; -} +const tempRoot = useLegacyTempWorkdir("supabase-report-config-"); -const readRules = (workdir: string) => +const readRules = (configToml?: string, projectEnv?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workdir = tempRoot.current; + if (configToml !== undefined) { + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", "config.toml"), configToml); + } + if (projectEnv !== undefined) { + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(workdir, "supabase", ".env"), projectEnv); + } return yield* legacyReadInspectRules(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true })), + ), + ), + ); describe("legacyReadInspectRules", () => { it.effect("returns [] when config.toml is absent", () => Effect.gen(function* () { - const rules = yield* readRules(makeWorkdir()); + const rules = yield* readRules(); expect(rules).toEqual([]); }), ); it.effect("returns [] when there are no inspect rules", () => Effect.gen(function* () { - const rules = yield* readRules(makeWorkdir('project_id = "demo"\n')); + const rules = yield* readRules('project_id = "demo"\n'); expect(rules).toEqual([]); }), ); @@ -41,16 +49,14 @@ describe("legacyReadInspectRules", () => { it.effect("parses [experimental.inspect.rules]", () => Effect.gen(function* () { const rules = yield* readRules( - makeWorkdir( - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT COUNT(*) FROM `locks.csv`"', - 'name = "No locks"', - 'pass = "ok"', - 'fail = "bad"', - "", - ].join("\n"), - ), + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT COUNT(*) FROM `locks.csv`"', + 'name = "No locks"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), ); expect(rules).toEqual([ { query: "SELECT COUNT(*) FROM `locks.csv`", name: "No locks", pass: "ok", fail: "bad" }, @@ -60,30 +66,27 @@ describe("legacyReadInspectRules", () => { it.effect("expands env(VAR) in rule string fields", () => Effect.gen(function* () { - process.env["LEGACY_REPORT_TEST_FAIL"] = "from-env"; const rules = yield* readRules( - makeWorkdir( - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT COUNT(*) FROM `locks.csv`"', - 'name = "r"', - 'pass = "ok"', - 'fail = "env(LEGACY_REPORT_TEST_FAIL)"', - "", - ].join("\n"), - ), + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT COUNT(*) FROM `locks.csv`"', + 'name = "r"', + 'pass = "ok"', + 'fail = "env(LEGACY_REPORT_TEST_FAIL)"', + "", + ].join("\n"), + "LEGACY_REPORT_TEST_FAIL=from-env\n", ); - delete process.env["LEGACY_REPORT_TEST_FAIL"]; expect(rules[0]?.fail).toBe("from-env"); }), ); it.effect("fails with LegacyDbConfigLoadError on a malformed config.toml", () => Effect.gen(function* () { - const exit = yield* Effect.exit(readRules(makeWorkdir("this is = = not valid toml [[["))); + const exit = yield* Effect.exit(readRules("this is = = not valid toml [[[")); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDbConfigLoadError"); } }), ); @@ -93,16 +96,14 @@ describe("legacyReadInspectRules", () => { // Weakly-typed decoding: an int/bool field // coerces to its string form (123 → "123", true → "1") rather than erroring. const rules = yield* readRules( - makeWorkdir( - [ - "[[experimental.inspect.rules]]", - "query = 123", - 'name = "r"', - "pass = true", - 'fail = "bad"', - "", - ].join("\n"), - ), + [ + "[[experimental.inspect.rules]]", + "query = 123", + 'name = "r"', + "pass = true", + 'fail = "bad"', + "", + ].join("\n"), ); expect(rules[0]?.query).toBe("123"); expect(rules[0]?.pass).toBe("1"); @@ -112,11 +113,11 @@ describe("legacyReadInspectRules", () => { it.effect("fails when an inspect.rules entry is not a table (Go aborts)", () => Effect.gen(function* () { const exit = yield* Effect.exit( - readRules(makeWorkdir('[experimental.inspect]\nrules = ["not-a-table"]\n')), + readRules('[experimental.inspect]\nrules = ["not-a-table"]\n'), ); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("expected a map or struct"); + expect(Formatter.formatJson(exit.cause)).toContain("expected a map or struct"); } }), ); @@ -125,22 +126,20 @@ describe("legacyReadInspectRules", () => { Effect.gen(function* () { const exit = yield* Effect.exit( readRules( - makeWorkdir( - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT 1"', - 'name = "r"', - 'pass = "ok"', - 'fail = "bad"', - 'fails = "typo"', - "", - ].join("\n"), - ), + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT 1"', + 'name = "r"', + 'pass = "ok"', + 'fail = "bad"', + 'fails = "typo"', + "", + ].join("\n"), ), ); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("invalid keys: fails"); + expect(Formatter.formatJson(exit.cause)).toContain("invalid keys: fails"); } }), ); @@ -148,16 +147,14 @@ describe("legacyReadInspectRules", () => { it.effect("accepts a single inline rules table as one rule (Go weak-typing wrap)", () => Effect.gen(function* () { const rules = yield* readRules( - makeWorkdir( - [ - "[experimental.inspect.rules]", - 'query = "SELECT 1"', - 'name = "solo"', - 'pass = "ok"', - 'fail = "bad"', - "", - ].join("\n"), - ), + [ + "[experimental.inspect.rules]", + 'query = "SELECT 1"', + 'name = "solo"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), ); expect(rules).toEqual([{ query: "SELECT 1", name: "solo", pass: "ok", fail: "bad" }]); }), @@ -165,12 +162,10 @@ describe("legacyReadInspectRules", () => { it.effect("fails when rules is a scalar string (Go aborts)", () => Effect.gen(function* () { - const exit = yield* Effect.exit( - readRules(makeWorkdir('[experimental.inspect]\nrules = "oops"\n')), - ); + const exit = yield* Effect.exit(readRules('[experimental.inspect]\nrules = "oops"\n')); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("expected a map or struct"); + expect(Formatter.formatJson(exit.cause)).toContain("expected a map or struct"); } }), ); @@ -179,19 +174,17 @@ describe("legacyReadInspectRules", () => { Effect.gen(function* () { const exit = yield* Effect.exit( readRules( - makeWorkdir( - [ - "[[experimental.inspect.rules]]", - "[experimental.inspect.rules.query]", - 'a = "b"', - "", - ].join("\n"), - ), + [ + "[[experimental.inspect.rules]]", + "[experimental.inspect.rules.query]", + 'a = "b"', + "", + ].join("\n"), ), ); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("expected a string"); + expect(Formatter.formatJson(exit.cause)).toContain("expected a string"); } }), ); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts index ca7d1b006b..356b712f61 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts @@ -1,4 +1,4 @@ -import { Option } from "effect"; +import { Data, Option } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, @@ -52,9 +52,13 @@ import { */ /** Thrown for grammar or evaluation outside the supported csvq subset. */ -export class LegacyInspectCsvqError extends Error { +export class LegacyInspectCsvqError extends Data.TaggedError("LegacyInspectCsvqError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInspectCsvqError"; - override readonly name = "LegacyInspectCsvqError"; + constructor(message: string) { + super({ message }); + } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.impossibleState; diff --git a/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts index 0af79d2e95..efb1efad06 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- legacy e2e exercises the subprocess and temporary filesystem boundary directly. import { existsSync, mkdtempSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts index 356125201c..53e0742c3b 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.handler.ts @@ -1,4 +1,4 @@ -import { Clock, Effect, FileSystem, Option, Path } from "effect"; +import { Clock, DateTime, Effect, FileSystem, Option, Path } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -10,6 +10,7 @@ import { legacyBold } from "../../../output/legacy-bold.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { legacyErrorMessage } from "../../../shared/legacy-error-message.ts"; import { resolveLegacyDbTargetFlags } from "../../../shared/legacy-db-target-flags.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { LegacyInspectMutuallyExclusiveFlagsError } from "../db/legacy-inspect-query.ts"; @@ -34,11 +35,7 @@ import { /** Local-time `YYYY-MM-DD`, the report's dated output folder format. */ function legacyReportDateFolder(epochMillis: number): string { - const date = new Date(epochMillis); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; + return DateTime.formatIsoDate(DateTime.makeUnsafe(epochMillis)); } /** @@ -79,11 +76,9 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( // it and route to linked incorrectly. const target = resolveLegacyDbTargetFlags(cliArgs.args); if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyInspectMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyInspectMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } // Read + validate the custom `[experimental.inspect.rules]` BEFORE any DB work, @@ -99,12 +94,10 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyInspectMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyInspectMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const cfg = yield* resolver.resolve({ @@ -122,13 +115,14 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( outDir = path.join(runtimeInfo.cwd, outDir); } // The output dir is pinned to 0755 and each CSV to 0644. - yield* fs - .makeDirectory(outDir, { recursive: true, mode: 0o755 }) - .pipe( - Effect.mapError( - (error) => new LegacyInspectReportMkdirError({ message: `failed to mkdir: ${error}` }), - ), - ); + yield* fs.makeDirectory(outDir, { recursive: true, mode: 0o755 }).pipe( + Effect.mapError( + (error) => + new LegacyInspectReportMkdirError({ + message: `failed to mkdir: ${legacyErrorMessage(error)}`, + }), + ), + ); // The connect diagnostic is written to stderr before dialing. if (isText) { @@ -153,7 +147,7 @@ const legacyRunInspectReport = Effect.fnUntraced(function* ( Effect.mapError( (error) => new LegacyInspectReportWriteError({ - message: `failed to create output file: ${error}`, + message: `failed to create output file: ${legacyErrorMessage(error)}`, }), ), ); diff --git a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts index ef0fe2a072..22ef7ee796 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts @@ -1,17 +1,26 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; -import { mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { + ConfigProvider, + DateTime, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Path, +} from "effect"; import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; @@ -49,8 +58,11 @@ for (const { fileName, sql } of LEGACY_REPORT_QUERIES) { ); } +const tempRoot = useLegacyTempWorkdir("supabase-inspect-report-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); + function tempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); + return path.join(tempRoot.current, prefix); } function mockResolver(opts: { conn?: LegacyPgConnInput; isLocal?: boolean; fails?: boolean } = {}) { @@ -131,6 +143,7 @@ interface SetupOpts { } function setupLegacyReport(opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); const out = mockOutput({ format: opts.format ?? "text" }); const resolver = mockResolver({ conn: opts.conn, @@ -146,6 +159,8 @@ function setupLegacyReport(opts: SetupOpts = {}) { const workdir = opts.workdir ?? tempDir("supabase-report-workdir-"); const layer = Layer.mergeAll( out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), resolver.layer, connection.layer, telemetry.layer, @@ -156,7 +171,7 @@ function setupLegacyReport(opts: SetupOpts = {}) { mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }), BunServices.layer, ); - return { layer, out, resolver, connection, telemetry, workdir }; + return { layer, configProvider, out, resolver, connection, telemetry, workdir }; } const flags = (over: Partial<LegacyInspectReportFlags> = {}): LegacyInspectReportFlags => ({ @@ -184,25 +199,30 @@ const DEFAULT_RULE_CSVS: Record<string, string> = { }; function localDateFolder(): string { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + return DateTime.formatIsoDate(DateTime.nowUnsafe()); } -function dateFolderContents(base: string): { dir: string; files: Array<string> } { - const entries = readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory()); - expect(entries.length).toBe(1); - const dir = join(base, entries[0]!.name); - return { dir, files: readdirSync(dir) }; +function dateFolderContents(base: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const entries = yield* fs.readDirectory(base); + const directories: Array<string> = []; + for (const entry of entries) { + if ((yield* fs.stat(path.join(base, entry))).type === "Directory") directories.push(entry); + } + expect(directories.length).toBe(1); + const dir = path.join(base, directories[0]!); + return { dir, files: yield* fs.readDirectory(dir) }; + }); } describe("legacy inspect report", () => { it.live("writes one CSV per inspect query for the linked project", () => { const base = tempDir("supabase-report-out-"); const { layer, connection } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); - const prevUmask = process.umask(0); return Effect.gen(function* () { yield* legacyInspectReport(flags({ outputDir: base })); - const { dir, files } = dateFolderContents(base); + const { dir, files } = yield* dateFolderContents(base); expect(files.length).toBe(14); expect(files).toContain("db_stats.csv"); expect(files).toContain("unused_indexes.csv"); @@ -215,9 +235,10 @@ describe("legacy inspect report", () => { ), ).toBe(true); // The date folder is pinned to 0755 and each CSV to 0644. - expect(statSync(dir).mode & 0o777).toBe(0o755); - expect(statSync(join(dir, "db_stats.csv")).mode & 0o777).toBe(0o644); - }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); + const fs = yield* FileSystem.FileSystem; + expect((yield* fs.stat(dir)).mode & 0o777).toBe(0o755); + expect((yield* fs.stat(path.join(dir, "db_stats.csv"))).mode & 0o777).toBe(0o644); + }).pipe(Effect.provide(layer)); }); it.live("inspects the local database with --local", () => { @@ -264,7 +285,7 @@ describe("legacy inspect report", () => { const exit = yield* Effect.exit(legacyInspectReport(flags({ linked: true, local: true }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("are set none of the others can be"); + expect(Formatter.formatJson(exit.cause)).toContain("are set none of the others can be"); } }).pipe(Effect.provide(layer)); }); @@ -289,8 +310,8 @@ describe("legacy inspect report", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("are set none of the others can be"); - expect(JSON.stringify(exit.cause)).toContain("[linked local]"); + expect(Formatter.formatJson(exit.cause)).toContain("are set none of the others can be"); + expect(Formatter.formatJson(exit.cause)).toContain("[linked local]"); } }).pipe(Effect.provide(layer)); }); @@ -336,7 +357,7 @@ describe("legacy inspect report", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } @@ -383,23 +404,24 @@ describe("legacy inspect report", () => { () => { const base = tempDir("supabase-report-out-"); const workdir = tempDir("supabase-report-workdir-"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "config.toml"), - [ - "[[experimental.inspect.rules]]", - "query = \"SELECT COUNT(*) FROM `locks.csv` WHERE granted = 'f'\"", - 'name = "Custom rule"', - 'pass = "good"', - 'fail = "bad"', - "", - ].join("\n"), - ); const { layer, out } = setupLegacyReport({ workdir, csvs: { "locks.csv": "stmt,granted\nA,t\n" }, }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + [ + "[[experimental.inspect.rules]]", + "query = \"SELECT COUNT(*) FROM `locks.csv` WHERE granted = 'f'\"", + 'name = "Custom rule"', + 'pass = "good"', + 'fail = "bad"', + "", + ].join("\n"), + ); yield* legacyInspectReport(flags({ outputDir: base })); expect(out.stderrText).not.toContain("Loading default rules..."); expect(out.stdoutText).toContain("Custom rule"); @@ -412,22 +434,23 @@ describe("legacy inspect report", () => { it.live("surfaces a malformed rule query as the STATUS cell without failing", () => { const base = tempDir("supabase-report-out-"); const workdir = tempDir("supabase-report-workdir-"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "config.toml"), - [ - "[[experimental.inspect.rules]]", - // References a CSV that was never produced — the provider returns no table - // and the evaluator surfaces the error as the STATUS cell (not a failure). - 'query = "SELECT COUNT(*) FROM `nope.csv`"', - 'name = "Broken rule"', - 'pass = "ok"', - 'fail = "bad"', - "", - ].join("\n"), - ); const { layer, out } = setupLegacyReport({ workdir, csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + [ + "[[experimental.inspect.rules]]", + // References a CSV that was never produced — the provider returns no table + // and the evaluator surfaces the error as the STATUS cell (not a failure). + 'query = "SELECT COUNT(*) FROM `nope.csv`"', + 'name = "Broken rule"', + 'pass = "ok"', + 'fail = "bad"', + "", + ].join("\n"), + ); const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stdoutText).toContain("Broken rule"); @@ -437,32 +460,34 @@ describe("legacy inspect report", () => { it.live("aborts on a malformed config.toml before connecting or writing any CSV", () => { const base = tempDir("supabase-report-out-"); const workdir = tempDir("supabase-report-workdir-"); - mkdirSync(join(workdir, "supabase"), { recursive: true }); - // An invalid rule config (unknown key) must abort before the DB connection - // and before any CSV files are written. - writeFileSync( - join(workdir, "supabase", "config.toml"), - [ - "[[experimental.inspect.rules]]", - 'query = "SELECT 1"', - 'name = "r"', - 'pass = "ok"', - 'fail = "bad"', - 'typo = "x"', - "", - ].join("\n"), - ); const { layer, connection } = setupLegacyReport({ workdir }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(workdir, "supabase"), { recursive: true }); + // An invalid rule config (unknown key) must abort before the DB connection + // and before any CSV files are written. + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + [ + "[[experimental.inspect.rules]]", + 'query = "SELECT 1"', + 'name = "r"', + 'pass = "ok"', + 'fail = "bad"', + 'typo = "x"', + "", + ].join("\n"), + ); + yield* fs.makeDirectory(base, { recursive: true }); const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("invalid keys: typo"); + expect(Formatter.formatJson(exit.cause)).toContain("invalid keys: typo"); } // No connection and no dated output folder — config validation ran first, // before mkdir / connect / COPY (base itself is the pre-created temp dir). expect(connection.copiedSql.length).toBe(0); - expect(readdirSync(base).length).toBe(0); + expect(yield* fs.readDirectory(base)).toHaveLength(0); }).pipe(Effect.provide(layer)); }); @@ -482,7 +507,7 @@ describe("legacy inspect report", () => { expect(data?.files?.length).toBe(14); expect(typeof data?.outputDir).toBe("string"); expect(data?.rules?.length).toBe(13); - expect(dateFolderContents(base).files.length).toBe(14); + expect((yield* dateFolderContents(base)).files.length).toBe(14); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -500,14 +525,16 @@ describe("legacy inspect report", () => { it.live("aborts with a failed-to-mkdir error when the output directory cannot be created", () => { // Point --output-dir at a regular file so mkdir of `<file>/<date>` fails. - const fileAsDir = join(tempDir("supabase-report-out-"), "afile"); - writeFileSync(fileAsDir, "x"); + const fileAsDir = path.join(tempDir("supabase-report-out-"), "afile"); const { layer } = setupLegacyReport(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.dirname(fileAsDir), { recursive: true }); + yield* fs.writeFileString(fileAsDir, "x"); const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: fileAsDir }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to mkdir"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to mkdir"); } }).pipe(Effect.provide(layer)); }); @@ -519,7 +546,7 @@ describe("legacy inspect report", () => { const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to copy output"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to copy output"); } }).pipe(Effect.provide(layer)); }); @@ -528,13 +555,14 @@ describe("legacy inspect report", () => { const base = tempDir("supabase-report-out-"); // Pre-create the first CSV target (`bloat.csv`) as a DIRECTORY so the file // write fails (EISDIR) while mkdir (recursive, idempotent) still succeeds. - mkdirSync(join(base, localDateFolder(), "bloat.csv"), { recursive: true }); const { layer } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(base, localDateFolder(), "bloat.csv"), { recursive: true }); const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to create output file"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to create output file"); } }).pipe(Effect.provide(layer)); }); @@ -546,7 +574,7 @@ describe("legacy inspect report", () => { const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to connect to postgres"); } }).pipe(Effect.provide(layer)); }); @@ -558,7 +586,7 @@ describe("legacy inspect report", () => { const exit = yield* Effect.exit(legacyInspectReport(flags({ outputDir: base }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("cannot load config"); + expect(Formatter.formatJson(exit.cause)).toContain("cannot load config"); } }).pipe(Effect.provide(layer)); }); @@ -568,7 +596,7 @@ describe("legacy inspect report", () => { const { layer } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, cwd }); return Effect.gen(function* () { yield* legacyInspectReport(flags({ outputDir: "reports" })); - const { files } = dateFolderContents(join(cwd, "reports")); + const { files } = yield* dateFolderContents(path.join(cwd, "reports")); expect(files.length).toBe(14); }).pipe(Effect.provide(layer)); }); @@ -578,10 +606,12 @@ describe("legacy inspect report", () => { const cwd = tempDir("supabase-report-cwd-"); const { layer } = setupLegacyReport({ csvs: DEFAULT_RULE_CSVS, cwd }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(cwd, { recursive: true }); yield* legacyInspectReport(flags({ outputDir: base })); // Written under the absolute base, not under the CWD. - expect(dateFolderContents(base).files.length).toBe(14); - expect(readdirSync(cwd).length).toBe(0); + expect((yield* dateFolderContents(base)).files.length).toBe(14); + expect(yield* fs.readDirectory(cwd)).toHaveLength(0); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/issue/issue.handler.ts b/apps/cli/src/legacy/commands/issue/issue.handler.ts index 54bfa628d5..87a401ec22 100644 --- a/apps/cli/src/legacy/commands/issue/issue.handler.ts +++ b/apps/cli/src/legacy/commands/issue/issue.handler.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Config, Effect, Option } from "effect"; import { buildIssueUrl, inferIssueInstallMethod, @@ -28,9 +28,19 @@ const legacyOpenIssueUrl = Effect.fnUntraced(function* (url: string, noBrowser: } }); +const issueInstallEnvironment = Effect.gen(function* () { + const installMethod = yield* Config.option(Config.string("SUPABASE_INSTALL_METHOD")); + const userAgent = yield* Config.option(Config.string("npm_config_user_agent")); + return { + SUPABASE_INSTALL_METHOD: Option.getOrUndefined(installMethod), + npm_config_user_agent: Option.getOrUndefined(userAgent), + }; +}); + export const legacyIssueBug = Effect.fn("legacy.issue.bug")(function* (flags: LegacyIssueBugFlags) { const runtimeInfo = yield* RuntimeInfo; const telemetryRuntime = yield* TelemetryRuntime; + const environment = yield* issueInstallEnvironment; const url = buildIssueUrl({ template: issueTemplateContract.bug.template, @@ -38,7 +48,7 @@ export const legacyIssueBug = Effect.fn("legacy.issue.bug")(function* (flags: Le "affected-area": readIssueFlagValue(flags.area), "cli-version": telemetryRuntime.cliVersion, os: `${runtimeInfo.platform} ${runtimeInfo.arch}`, - "install-method": inferIssueInstallMethod(runtimeInfo), + "install-method": inferIssueInstallMethod(runtimeInfo, environment), command: readIssueFlagValue(flags.command), "actual-output": readIssueFlagValue(flags.actualOutput), "expected-behavior": readIssueFlagValue(flags.expectedBehavior), diff --git a/apps/cli/src/legacy/commands/issue/issue.integration.test.ts b/apps/cli/src/legacy/commands/issue/issue.integration.test.ts index 00612e88f4..1f89a8cab1 100644 --- a/apps/cli/src/legacy/commands/issue/issue.integration.test.ts +++ b/apps/cli/src/legacy/commands/issue/issue.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; import { buildIssueUrl } from "../../../shared/issue/issue-url.ts"; import { Output } from "../../../shared/output/output.service.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; @@ -15,32 +15,6 @@ type LegacyIssueOutputMessage = { readonly data?: Record<string, unknown>; }; -function legacyIssueProcessEnvLayer(values: Readonly<Record<string, string | undefined>> = {}) { - return Layer.effectDiscard( - Effect.acquireRelease( - Effect.sync(() => { - const snapshot = { ...process.env }; - for (const key of Object.keys(process.env)) { - delete process.env[key]; - } - for (const [key, value] of Object.entries(values)) { - if (value !== undefined) process.env[key] = value; - } - return snapshot; - }), - (snapshot) => - Effect.sync(() => { - for (const key of Object.keys(process.env)) { - delete process.env[key]; - } - for (const [key, value] of Object.entries(snapshot)) { - if (value !== undefined) process.env[key] = value; - } - }), - ), - ); -} - function legacyIssueMockOutput(opts: { readonly format?: OutputFormat } = {}) { const messages: LegacyIssueOutputMessage[] = []; const rawChunks: string[] = []; @@ -156,7 +130,9 @@ function legacyIssueSetup( browser.layer, runtimeInfo, telemetryRuntime, - legacyIssueProcessEnvLayer(opts.env ?? {}), + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), ); return { layer, out, browser }; } diff --git a/apps/cli/src/legacy/commands/link/link.e2e.test.ts b/apps/cli/src/legacy/commands/link/link.e2e.test.ts index 63e6e72c08..9ecfa0d76f 100644 --- a/apps/cli/src/legacy/commands/link/link.e2e.test.ts +++ b/apps/cli/src/legacy/commands/link/link.e2e.test.ts @@ -12,13 +12,13 @@ describe("supabase link (legacy)", () => { test( "without a resolvable project ref exits 1 with the required-flag error", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["link"], { + () => + runSupabase(["link"], { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, - }); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain(`required flag(s) "project-ref" not set`); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain(`required flag(s) "project-ref" not set`); + }), ); }); diff --git a/apps/cli/src/legacy/commands/link/link.handler.ts b/apps/cli/src/legacy/commands/link/link.handler.ts index 2f506b8fcb..483893b991 100644 --- a/apps/cli/src/legacy/commands/link/link.handler.ts +++ b/apps/cli/src/legacy/commands/link/link.handler.ts @@ -1,5 +1,5 @@ import type { ApiClient, V1ListAllBranchesOutput } from "@supabase/api/effect"; -import { Duration, Effect, FileSystem, Option, Path } from "effect"; +import { Duration, Effect, FileSystem, Option, Path, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -57,6 +57,16 @@ type LegacyLinkProject = Effect.Success<ReturnType<ApiClient["v1"]["getProject"] type LegacyLinkBranches = typeof V1ListAllBranchesOutput.Type; type LegacyLinkBranch = LegacyLinkBranches[number]; +const LegacyLinkedProjectCacheWriteSchema = Schema.Struct({ + ref: Schema.String, + name: Schema.optional(Schema.String), + organization_id: Schema.optional(Schema.String), + organization_slug: Schema.optional(Schema.String), +}); + +const encodeLinkedProjectCache = (value: typeof LegacyLinkedProjectCacheWriteSchema.Type) => + Schema.encodeEffect(Schema.fromJsonString(LegacyLinkedProjectCacheWriteSchema))(value); + /** Result of resolving a branch name/UUID to its project ref, threaded into the * machine payload (`branch`, `parent_project_ref`) alongside the plain ref. */ interface LegacyLinkBranchResolution { @@ -201,16 +211,14 @@ const resolveLegacyLinkBranchRef = Effect.fnUntraced(function* (value: string) { const parent = yield* legacyResolveLinkedParentRef(); if (parent.kind === "absent") { - return yield* Effect.fail( - new LegacyLinkBranchNotLinkedError({ message: legacyLinkNotLinkedMessage(value) }), - ); + return yield* new LegacyLinkBranchNotLinkedError({ + message: legacyLinkNotLinkedMessage(value), + }); } if (parent.kind === "invalid") { - return yield* Effect.fail( - new LegacyLinkParentRefInvalidError({ - message: `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref <parent-ref>`, - }), - ); + return yield* new LegacyLinkParentRefInvalidError({ + message: `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref <parent-ref>`, + }); } const parentRef = parent.ref; @@ -238,21 +246,17 @@ const resolveLegacyLinkBranchRef = Effect.fnUntraced(function* (value: string) { (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), ); if (found === undefined) { - return yield* Effect.fail( - new LegacyLinkBranchNotFoundError({ - message: legacyLinkBranchNotFoundMessage(value, parentRef, branches), - }), - ); + return yield* new LegacyLinkBranchNotFoundError({ + message: legacyLinkBranchNotFoundMessage(value, parentRef, branches), + }); } if (!PROJECT_REF_PATTERN.test(found.project_ref)) { - return yield* Effect.fail( - new LegacyLinkBranchNotReadyError({ - branch: found.name, - status: found.status, - message: `Branch "${legacySanitizeInlineName(found.name)}" has no project ref yet (status: ${found.status}). Wait for it to finish provisioning, then retry.`, - }), - ); + return yield* new LegacyLinkBranchNotReadyError({ + branch: found.name, + status: found.status, + message: `Branch "${legacySanitizeInlineName(found.name)}" has no project ref yet (status: ${found.status}). Wait for it to finish provisioning, then retry.`, + }); } const line = `Resolved branch "${legacySanitizeInlineName(found.name)}" of project ${parentRef} to project ref ${found.project_ref}.`; @@ -294,12 +298,10 @@ export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkF const projectRefFlag = Option.filter(flags.projectRef, (value) => value.length > 0); if (Option.isSome(refArg) && Option.isSome(projectRefFlag)) { - return yield* Effect.fail( - new LegacyLinkRefArgConflictError({ - message: - "Cannot use both the [ref-or-branch] argument and the --project-ref flag. Specify the project ref or branch name once.", - }), - ); + return yield* new LegacyLinkRefArgConflictError({ + message: + "Cannot use both the [ref-or-branch] argument and the --project-ref flag. Specify the project ref or branch name once.", + }); } const requested = Option.isSome(refArg) ? refArg : projectRefFlag; @@ -332,14 +334,12 @@ export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkF if (Option.isSome(project)) { const status = project.value.status; if (status === "INACTIVE") { - return yield* Effect.fail( - new LegacyProjectPausedError({ - message: "project is paused", - suggestion: `An admin must unpause it from the Supabase dashboard at ${legacyDashboardUrl( - cliConfig.profile, - )}/project/${ref}`, - }), - ); + return yield* new LegacyProjectPausedError({ + message: "project is paused", + suggestion: `An admin must unpause it from the Supabase dashboard at ${legacyDashboardUrl( + cliConfig.profile, + )}/project/${ref}`, + }); } if (status !== "ACTIVE_HEALTHY") { yield* output.raw( @@ -360,7 +360,7 @@ export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkF .pipe(Effect.catch(mapApiKeysError)); const { anon, serviceRole } = legacyExtractServiceKeys(keys); if (anon.length === 0 && serviceRole.length === 0) { - return yield* Effect.fail(new LegacyLinkMissingKeyError({ message: "Anon key not found." })); + return yield* new LegacyLinkMissingKeyError({ message: "Anon key not found." }); } // 3. Link services — best-effort, using the service-role key for tenant probes. @@ -383,15 +383,13 @@ export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkF // rewrite fails while a stale cache for a DIFFERENT project survives, // delete it rather than leave the parent chain trusting the old // project — no cache beats a wrong one. - yield* writeTempFile( - paths.linkedProjectCache, - JSON.stringify({ - ref: p.ref, - name: p.name, - organization_id: p.organization_id, - organization_slug: p.organization_slug, - }), - ).pipe( + const encodedCache = yield* encodeLinkedProjectCache({ + ref: p.ref, + name: p.name, + organization_id: p.organization_id, + organization_slug: p.organization_slug, + }); + yield* writeTempFile(paths.linkedProjectCache, encodedCache).pipe( Effect.catch(() => fs.remove(paths.linkedProjectCache, { force: true })), Effect.ignore, ); @@ -455,7 +453,8 @@ export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkF // best-effort; the mandatory `project-ref` write in the same // directory already succeeded, so a residual double-failure here // is practically unreachable. - yield* writeTempFile(paths.linkedProjectCache, JSON.stringify({ ref: parentRef })).pipe( + const encodedCache = yield* encodeLinkedProjectCache({ ref: parentRef }); + yield* writeTempFile(paths.linkedProjectCache, encodedCache).pipe( Effect.catch(() => fs.remove(paths.linkedProjectCache, { force: true })), Effect.ignore, ); @@ -505,7 +504,7 @@ export const legacyLink = Effect.fn("legacy.link")(function* (flags: LegacyLinkF const verified = yield* api.v1.listAllBranches({ ref: cachedParent.value.ref }).pipe( Effect.timeout(LEGACY_LINK_CACHE_CORRELATION_TIMEOUT), Effect.map((branches) => branches.some((branch) => branch.project_ref === ref)), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), Effect.ensuring(correlating?.clear() ?? Effect.void), ); if (!verified) { diff --git a/apps/cli/src/legacy/commands/link/link.integration.test.ts b/apps/cli/src/legacy/commands/link/link.integration.test.ts index 548b065e2c..052c10e5ae 100644 --- a/apps/cli/src/legacy/commands/link/link.integration.test.ts +++ b/apps/cli/src/legacy/commands/link/link.integration.test.ts @@ -1,14 +1,24 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; -import { Effect, Exit, Layer, Option, Stdio } from "effect"; +import { + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Schema, + Stdio, +} from "effect"; +import * as Formatter from "effect/Formatter"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import type * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequestModule from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; @@ -23,10 +33,14 @@ import { mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; + import { legacyLink } from "./link.handler.ts"; import { legacyLinkHandler } from "./link.command.ts"; import type { LegacyLinkFlags } from "./link.command.ts"; +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + const HEALTHY_PROJECT = { id: LEGACY_VALID_REF, ref: LEGACY_VALID_REF, @@ -149,13 +163,34 @@ function manyBranches(count: number): LegacyLinkBranches { })); } +const LinkedProjectCacheSchema = Schema.Struct({ + ref: Schema.String, + name: Schema.optional(Schema.String), + organization_id: Schema.optional(Schema.String), + organization_slug: Schema.optional(Schema.String), +}); +const LinkedProjectCacheCodec = Schema.fromJsonString(LinkedProjectCacheSchema); +const encodeLinkedProjectCache = Schema.encodeSync(LinkedProjectCacheCodec); +const decodeLinkedProjectCache = Schema.decodeSync(LinkedProjectCacheCodec); +const LinkedProjectRefSchema = Schema.Struct({ ref: Schema.String }); +const encodeLinkedProjectRef = Schema.encodeSync(Schema.fromJsonString(LinkedProjectRefSchema)); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const TenantRestResponseSchema = Schema.Struct({ + info: Schema.Struct({ version: Schema.String }), +}); +const encodeTenantRestResponse = Schema.encodeSync(Schema.fromJsonString(TenantRestResponseSchema)); +const TenantVersionResponseSchema = Schema.Struct({ version: Schema.String }); +const encodeTenantVersionResponse = Schema.encodeSync( + Schema.fromJsonString(TenantVersionResponseSchema), +); + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- interface V1StubResult { readonly ok?: unknown; - readonly fail?: unknown; + readonly fail?: HttpClientError.HttpClientError; } interface SetupOpts { @@ -174,10 +209,17 @@ interface SetupOpts { } const tempRoot = useLegacyTempWorkdir("supabase-link-int-"); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const pendingWrites = new Map< + string, + Array<{ readonly path: string; readonly contents: string }> +>(); function stub(result: V1StubResult | undefined, defaultOk: unknown) { - if (result?.fail !== undefined) return () => Effect.fail(result.fail); - return () => Effect.succeed(result?.ok ?? defaultOk); + const failure = result?.fail; + if (failure !== undefined) return () => Effect.fail(failure); + const value = result?.ok ?? defaultOk; + return () => Effect.succeed(value); } function tenantHttpLayer(opts: SetupOpts): Layer.Layer<HttpClient.HttpClient> { @@ -186,25 +228,31 @@ function tenantHttpLayer(opts: SetupOpts): Layer.Layer<HttpClient.HttpClient> { HttpClient.make((request) => Effect.gen(function* () { if (opts.tenant === "fail") { - return yield* Effect.fail(legacyTransportFailure(request)); + return yield* legacyTransportFailure(request); } const url = request.url; if (url.includes("/rest/v1/")) { return HttpClientResponse.fromWeb( request, - new Response(JSON.stringify({ info: { version: opts.restVersion ?? "11.1.0" } }), { - status: 200, - headers: { "content-type": "application/json" }, - }), + new Response( + encodeTenantRestResponse({ info: { version: opts.restVersion ?? "11.1.0" } }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), ); } if (url.includes("/auth/v1/health")) { return HttpClientResponse.fromWeb( request, - new Response(JSON.stringify({ version: opts.gotrueVersion ?? "v2.74.2" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), + new Response( + encodeTenantVersionResponse({ version: opts.gotrueVersion ?? "v2.74.2" }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), ); } if (url.includes("/storage/v1/version")) { @@ -237,14 +285,19 @@ function setup(opts: SetupOpts = {}) { workdir: tempRoot.current, projectId: opts.projectId ?? Option.none(), }); - const layer = buildLegacyTestRuntime({ - out, - api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer(opts) }, - cliConfig, - analytics, - telemetry: telemetry.layer, - linkedProjectCache: linkedCache.layer, - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer(opts) }, + cliConfig, + analytics, + telemetry: telemetry.layer, + linkedProjectCache: linkedCache.layer, + }), + Layer.effectDiscard( + flushFixtureWrites(tempRoot.current).pipe(Effect.provide(BunServices.layer)), + ), + ); return { layer, out, analytics, telemetry, linkedCache, apiMock, workdir: tempRoot.current }; } @@ -257,20 +310,39 @@ const flags = (overrides: Partial<LegacyLinkFlags> = {}): LegacyLinkFlags => ({ }); function tempFile(workdir: string, name: string): string { - return join(workdir, "supabase", ".temp", name); + return fixturePath.join(workdir, "supabase", ".temp", name); } -function readTemp(workdir: string, name: string): string { - return readFileSync(tempFile(workdir, name), "utf8"); +function readTemp(workdir: string, name: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(tempFile(workdir, name)); + }); } -function existsTemp(workdir: string, name: string): boolean { - return existsSync(tempFile(workdir, name)); +function existsTemp(workdir: string, name: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(tempFile(workdir, name)).pipe(Effect.orElseSucceed(() => false)); + }); } function writeTempContent(workdir: string, name: string, content: string): void { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(tempFile(workdir, name), content); + const writes = pendingWrites.get(workdir) ?? []; + writes.push({ path: tempFile(workdir, name), contents: content }); + pendingWrites.set(workdir, writes); +} + +function flushFixtureWrites(workdir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const writes = pendingWrites.get(workdir) ?? []; + for (const write of writes) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingWrites.delete(workdir); + }); } // Seeds `<workdir>/supabase/.temp/project-ref` — the 3rd-priority parent @@ -287,7 +359,7 @@ function writeLinkedProjectCacheFile(workdir: string, content: string): void { } function linkedProjectCacheJson(ref: string): string { - return JSON.stringify({ + return encodeLinkedProjectCache({ ref, name: "Parent Project", organization_id: "org_123", @@ -356,14 +428,14 @@ describe("legacy link integration", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { yield* legacyLink(flags()); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); - expect(readTemp(workdir, "postgres-version")).toBe("15.1.0.117"); - expect(readTemp(workdir, "storage-migration")).toBe("2026-01-01-000000"); - expect(readTemp(workdir, "rest-version")).toBe("v11.1.0"); - expect(readTemp(workdir, "gotrue-version")).toBe("v2.74.2"); - expect(readTemp(workdir, "storage-version")).toBe("v1.28.0"); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "postgres-version")).toBe("15.1.0.117"); + expect(yield* readTemp(workdir, "storage-migration")).toBe("2026-01-01-000000"); + expect(yield* readTemp(workdir, "rest-version")).toBe("v11.1.0"); + expect(yield* readTemp(workdir, "gotrue-version")).toBe("v2.74.2"); + expect(yield* readTemp(workdir, "storage-version")).toBe("v1.28.0"); // [YOUR-PASSWORD] stripped + transaction-mode port rewritten to 5432. - expect(readTemp(workdir, "pooler-url")).toBe( + expect(yield* readTemp(workdir, "pooler-url")).toBe( "postgresql://postgres.ref@pooler.example.co:5432/postgres", ); expect(out.stdoutText).toContain("Finished supabase link."); @@ -374,7 +446,7 @@ describe("legacy link integration", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { yield* legacyLink(flags()); - const linked = JSON.parse(readTemp(workdir, "linked-project.json")); + const linked = decodeLinkedProjectCache(yield* readTemp(workdir, "linked-project.json")); expect(linked).toEqual({ ref: LEGACY_VALID_REF, name: "My Project", @@ -413,7 +485,7 @@ describe("legacy link integration", () => { const { layer, workdir } = setup({ projectId: Option.some(LEGACY_VALID_REF) }); return Effect.gen(function* () { yield* legacyLink(flags({ projectRef: Option.none() })); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); }).pipe(Effect.provide(layer)); }); @@ -423,7 +495,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some(POSITIONAL_REF), projectRef: Option.none() }), ); - expect(readTemp(workdir, "project-ref")).toBe(POSITIONAL_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(POSITIONAL_REF); }).pipe(Effect.provide(layer)); }); @@ -433,7 +505,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags({ projectRef: Option.none() }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyProjectRefRequiredError"); expect(json).toContain(`required flag(s) \\"project-ref\\" not set`); } @@ -448,7 +520,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags({ projectRef: Option.none() }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }, @@ -460,10 +532,10 @@ describe("legacy link integration", () => { }); return Effect.gen(function* () { yield* legacyLink(flags()); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); // No postgres-version / linked-project.json and no telemetry for a 404. - expect(existsSync(tempFile(workdir, "postgres-version"))).toBe(false); - expect(existsSync(tempFile(workdir, "linked-project.json"))).toBe(false); + expect(yield* existsTemp(workdir, "postgres-version")).toBe(false); + expect(yield* existsTemp(workdir, "linked-project.json")).toBe(false); // This is a plain ref link that happens to 404 (assumed to be a branch), // with NO name/UUID resolution — `branchResolution` never fired, so the // CLI-2167 branch-link telemetry extension doesn't fire either. Emits @@ -482,7 +554,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyProjectPausedError"); expect(json).toContain("project is paused"); expect(json).toContain( @@ -501,7 +573,7 @@ describe("legacy link integration", () => { expect(out.stderrText).toContain( "WARNING: Project status is COMING_UP instead of Active Healthy. Some operations might fail.", ); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); }).pipe(Effect.provide(layer)); }); @@ -511,7 +583,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkProjectStatusError"); expect(json).toContain("Unexpected error retrieving remote project status"); } @@ -524,7 +596,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkAuthTokenError"); expect(json).toContain("Authorization failed for the access token and project ref pair"); } @@ -537,7 +609,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkMissingKeyError"); expect(json).toContain("Anon key not found."); } @@ -556,7 +628,7 @@ describe("legacy link integration", () => { }); return Effect.gen(function* () { yield* legacyLink(flags()); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); expect(out.stdoutText).toContain("Finished supabase link."); }).pipe(Effect.provide(layer)); }); @@ -580,7 +652,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLinkMissingKeyError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyLinkMissingKeyError"); } }).pipe(Effect.provide(layer)); }); @@ -594,11 +666,11 @@ describe("legacy link integration", () => { return Effect.gen(function* () { yield* legacyLink(flags()); // Link still succeeds and writes the project-ref. - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); expect(out.stdoutText).toContain("Finished supabase link."); // The best-effort files are absent because their services errored. - expect(existsSync(tempFile(workdir, "storage-migration"))).toBe(false); - expect(existsSync(tempFile(workdir, "rest-version"))).toBe(false); + expect(yield* existsTemp(workdir, "storage-migration")).toBe(false); + expect(yield* existsTemp(workdir, "rest-version")).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -607,7 +679,7 @@ describe("legacy link integration", () => { writeTempContent(workdir, "pooler-url", "stale-pooler-url"); return Effect.gen(function* () { yield* legacyLink(flags({ skipPooler: true })); - expect(existsSync(tempFile(workdir, "pooler-url"))).toBe(false); + expect(yield* existsTemp(workdir, "pooler-url")).toBe(false); expect(apiMock.requests.map((r) => r.method)).not.toContain("getPoolerConfig"); }).pipe(Effect.provide(layer)); }); @@ -638,11 +710,12 @@ describe("legacy link integration", () => { api: { layer: apiMock.layer, httpClientLayer: tenantHttpLayer({ tenant: "fail" }) }, cliConfig, }); - writeFileSync(join(tempRoot.current, "supabase"), "not-a-dir"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(fixturePath.join(tempRoot.current, "supabase"), "not-a-dir"); const exit = yield* Effect.exit(legacyLink(flags())); expect(Exit.isFailure(exit)).toBe(true); - expect(existsSync(tempFile(tempRoot.current, "project-ref"))).toBe(false); + expect(yield* existsTemp(tempRoot.current, "project-ref")).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -663,7 +736,7 @@ describe("legacy link integration", () => { expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); expect(success?.data).not.toHaveProperty("branch"); expect(out.stdoutText).not.toContain("Finished supabase link."); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); }).pipe(Effect.provide(layer)); }); @@ -688,7 +761,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkRefArgConflictError"); expect(json).toContain( "Cannot use both the [ref-or-branch] argument and the --project-ref flag.", @@ -713,7 +786,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyProjectRefRequiredError"); expect(json).toContain(`required flag(s) \\"project-ref\\" not set`); } @@ -729,7 +802,7 @@ describe("legacy link integration", () => { const exit = yield* Effect.exit(legacyLink(flags({ projectRef: Option.some("") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyProjectRefRequiredError"); expect(json).toContain(`required flag(s) \\"project-ref\\" not set`); } @@ -745,7 +818,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some(""), projectRef: Option.some(LEGACY_VALID_REF) }), ); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); }).pipe(Effect.provide(layer)); }, ); @@ -758,7 +831,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some(LEGACY_VALID_REF), projectRef: Option.none() }), ); - expect(readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(LEGACY_VALID_REF); expect(apiMock.requests.map((r) => r.method)).not.toContain("listAllBranches"); }).pipe(Effect.provide(layer)); }, @@ -785,10 +858,12 @@ describe("legacy link integration", () => { const branchCall = apiMock.requests.find((r) => r.method === "listAllBranches"); // The dealbreaker bug: this must be the PARENT ref, never BRANCH_PROJECT_REF. expect(branchCall?.input).toMatchObject({ ref: PARENT_REF }); - expect(readTemp(workdir, "project-ref")).toBe(OTHER_BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(OTHER_BRANCH_PROJECT_REF); // The 404 branch-link path leaves the cache untouched — the invariant a // THIRD relink still depends on. - expect(readTemp(workdir, "linked-project.json")).toBe(linkedProjectCacheJson(PARENT_REF)); + expect(yield* readTemp(workdir, "linked-project.json")).toBe( + linkedProjectCacheJson(PARENT_REF), + ); }).pipe(Effect.provide(layer)); }, ); @@ -809,7 +884,7 @@ describe("legacy link integration", () => { ); const branchCall = apiMock.requests.find((r) => r.method === "listAllBranches"); expect(branchCall?.input).toMatchObject({ ref: PARENT_REF }); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); }).pipe(Effect.provide(layer)); }, ); @@ -835,7 +910,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLinkParentRefInvalidError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyLinkParentRefInvalidError"); } expect(apiMock.requests.find((r) => r.method === "listAllBranches")).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -849,9 +924,9 @@ describe("legacy link integration", () => { const corruptCacheContents = [ "not json at all {", "null", - JSON.stringify({ notRef: "x" }), - JSON.stringify({ ref: 12345 }), - JSON.stringify({ ref: "" }), + encodeJson({ notRef: "x" }), + encodeJson({ ref: 12345 }), + encodeJson({ ref: "" }), ]; return Effect.gen(function* () { for (const content of corruptCacheContents) { @@ -860,6 +935,7 @@ describe("legacy link integration", () => { // would otherwise clobber this fixture before the next check runs. writeLinkedParentRef(workdir, FILE_ONLY_REF); writeLinkedProjectCacheFile(workdir, content); + yield* flushFixtureWrites(workdir); yield* legacyLink( flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), ); @@ -885,7 +961,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkParentRefInvalidError"); expect(json).toContain( `Cannot resolve branch \\"feature-branch\\": the linked project ref is invalid`, @@ -910,7 +986,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotLinkedError"); expect(json).toContain(`Cannot resolve \\"feature-branch\\": it is not a project ref`); expect(json).toContain( @@ -940,7 +1016,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotLinkedError"); expect(json).toContain(`Cannot resolve \\"feature-branch\\": it is not a project ref`); } @@ -956,8 +1032,9 @@ describe("legacy link integration", () => { // A directory at the project-ref path makes `fs.readFileString` fail with // a real (non-not-exist) read error, exercising the defensive fallback // distinct from the plain "file missing" case. - mkdirSync(tempFile(workdir, "project-ref"), { recursive: true }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(tempFile(workdir, "project-ref"), { recursive: true }); const exit = yield* Effect.exit( legacyLink( flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), @@ -965,7 +1042,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLinkBranchNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyLinkBranchNotLinkedError"); } expect(apiMock.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -986,8 +1063,8 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), ); - expect(readTemp(workdir, "linked-project.json")).toBe( - JSON.stringify({ ref: PARENT_REF }), + expect(yield* readTemp(workdir, "linked-project.json")).toBe( + encodeLinkedProjectRef({ ref: PARENT_REF }), ); // Follow-up: a second branch-name link must still resolve via the @@ -1000,7 +1077,7 @@ describe("legacy link integration", () => { ); const branchCalls = apiMock.requests.filter((r) => r.method === "listAllBranches"); expect(branchCalls.at(-1)?.input).toMatchObject({ ref: PARENT_REF }); - expect(readTemp(workdir, "project-ref")).toBe(OTHER_BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(OTHER_BRANCH_PROJECT_REF); }).pipe(Effect.provide(layer)); }, ); @@ -1019,7 +1096,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), ); - expect(readTemp(workdir, "linked-project.json")).toBe(richCache); + expect(yield* readTemp(workdir, "linked-project.json")).toBe(richCache); }).pipe(Effect.provide(layer)); }, ); @@ -1035,8 +1112,8 @@ describe("legacy link integration", () => { writeLinkedProjectCacheFile(workdir, cacheContent); return Effect.gen(function* () { yield* legacyLink(flags({ projectRef: Option.some(BRANCH_PROJECT_REF) })); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); - expect(readTemp(workdir, "linked-project.json")).toBe(cacheContent); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "linked-project.json")).toBe(cacheContent); const branchCall = apiMock.requests.find((r) => r.method === "listAllBranches"); expect(branchCall?.input).toMatchObject({ ref: CACHE_ONLY_REF }); }).pipe(Effect.provide(layer)); @@ -1053,8 +1130,8 @@ describe("legacy link integration", () => { writeLinkedProjectCacheFile(workdir, linkedProjectCacheJson(CACHE_ONLY_REF)); return Effect.gen(function* () { yield* legacyLink(flags({ projectRef: Option.some(BRANCH_PROJECT_REF) })); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); - expect(existsSync(tempFile(workdir, "linked-project.json"))).toBe(false); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* existsTemp(workdir, "linked-project.json")).toBe(false); }).pipe(Effect.provide(layer)); }, ); @@ -1074,8 +1151,8 @@ describe("legacy link integration", () => { writeLinkedProjectCacheFile(workdir, cacheContent); return Effect.gen(function* () { yield* legacyLink(flags({ projectRef: Option.some(BRANCH_PROJECT_REF) })); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); - expect(existsTemp(workdir, "linked-project.json")).toBe(false); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* existsTemp(workdir, "linked-project.json")).toBe(false); }).pipe(Effect.provide(layer)); }, ); @@ -1091,7 +1168,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), ); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); const branchRequest = apiMock.requests.find((r) => r.method === "listAllBranches"); expect(branchRequest?.input).toMatchObject({ ref: PARENT_REF }); }).pipe(Effect.provide(layer)); @@ -1105,7 +1182,7 @@ describe("legacy link integration", () => { writeLinkedParentRef(workdir, PARENT_REF); return Effect.gen(function* () { yield* legacyLink(flags({ projectRef: Option.some("feature-branch") })); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); }).pipe(Effect.provide(layer)); }, ); @@ -1117,7 +1194,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some(LINK_BRANCH.id), projectRef: Option.none() }), ); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); }).pipe(Effect.provide(layer)); }); @@ -1131,7 +1208,7 @@ describe("legacy link integration", () => { projectRef: Option.none(), }), ); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); }).pipe(Effect.provide(layer)); }); @@ -1150,14 +1227,14 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotReadyError"); expect(json).toContain( `Branch \\"feature-branch\\" has no project ref yet (status: CREATING_PROJECT)`, ); } // No project-ref written, and no attempt to link the parent (env) ref instead. - expect(existsSync(tempFile(workdir, "project-ref"))).toBe(false); + expect(yield* existsTemp(workdir, "project-ref")).toBe(false); expect(apiMock.requests).toEqual([ { method: "listAllBranches", input: { ref: PARENT_REF } }, ]); @@ -1177,7 +1254,7 @@ describe("legacy link integration", () => { ), ); expect(Exit.isFailure(exit)).toBe(true); - expect(readTemp(workdir, "project-ref")).toBe(PARENT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(PARENT_REF); }).pipe(Effect.provide(layer)); }); }); @@ -1194,7 +1271,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotFoundError"); expect(json).toContain("branch-00"); expect(json).toContain("branch-19"); @@ -1215,7 +1292,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain(`Did you mean \\"staging\\"?`); // "Staging" has an uppercase letter, so no ref-typo hint. expect(json).not.toContain("If you meant a project ref"); @@ -1236,7 +1313,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotFoundError"); expect(json).toContain( `Branch \\"missingbranch\\" not found: project ${PARENT_REF} has no branches.`, @@ -1258,7 +1335,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotFoundError"); expect(json).toContain(`Branch \\"my-branch\\" not found for project ${PARENT_REF}.`); expect(json).not.toContain("If you meant a project ref"); @@ -1279,7 +1356,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchNotFoundError"); expect(json).toContain( `Branch \\"missing-branch\\" not found for project ${PARENT_REF}. Available branches: alpha, zeta`, @@ -1309,7 +1386,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchListStatusError"); expect(json).toContain(`Cannot list branches for project ${PARENT_REF} (HTTP 404)`); expect(json).toContain( @@ -1333,7 +1410,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchListStatusError"); expect(json).toContain("unexpected list branches status 500"); } @@ -1354,7 +1431,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchListNetworkError"); expect(json).toContain("failed to list branches:"); } @@ -1397,7 +1474,7 @@ describe("legacy link integration", () => { yield* legacyLink( flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() }), ); - expect(readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); + expect(yield* readTemp(workdir, "project-ref")).toBe(BRANCH_PROJECT_REF); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ project_ref: BRANCH_PROJECT_REF, @@ -1425,7 +1502,7 @@ describe("legacy link integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyLinkBranchListStatusError"); } expect(out.progressEvents).toEqual([]); @@ -1482,7 +1559,7 @@ describe("legacy link integration", () => { expect(analytics.groupIdentified).toHaveLength(0); // The branch NAME is user-created content and must never leave the // machine in any captured analytics payload. - expect(JSON.stringify(analytics.captured)).not.toContain("feature-branch"); + expect(Formatter.formatJson(analytics.captured)).not.toContain("feature-branch"); }).pipe(Effect.provide(layer)); }, ); @@ -1522,7 +1599,7 @@ describe("legacy link integration", () => { properties: { name: "My Project", organization_slug: "acme" }, }, ]); - expect(JSON.stringify(analytics.captured)).not.toContain('"main"'); + expect(Formatter.formatJson(analytics.captured)).not.toContain('"main"'); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/legacy/commands/link/link.live.test.ts b/apps/cli/src/legacy/commands/link/link.live.test.ts index 47bf065f1d..ddb690d720 100644 --- a/apps/cli/src/legacy/commands/link/link.live.test.ts +++ b/apps/cli/src/legacy/commands/link/link.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and inspects host files. import { existsSync } from "node:fs"; import { join } from "node:path"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/login/login.e2e.test.ts b/apps/cli/src/legacy/commands/login/login.e2e.test.ts index 2e0eda72ee..4848c1d9d0 100644 --- a/apps/cli/src/legacy/commands/login/login.e2e.test.ts +++ b/apps/cli/src/legacy/commands/login/login.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- legacy e2e exercises the subprocess and temporary filesystem boundary directly. import { existsSync } from "node:fs"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/login/login.handler.ts b/apps/cli/src/legacy/commands/login/login.handler.ts index 8cdb8d3882..1b9440b205 100644 --- a/apps/cli/src/legacy/commands/login/login.handler.ts +++ b/apps/cli/src/legacy/commands/login/login.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path, Redacted } from "effect"; +import { Config, Effect, FileSystem, Option, Path, Redacted } from "effect"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; @@ -12,6 +12,7 @@ import { import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { lastExplicitLongFlagValue } from "../../../shared/cli/cobra-flag-groups.ts"; import { LegacyProfileFlag } from "../../../shared/legacy/global-flags.ts"; +import { LegacyViperEnv } from "../../../shared/legacy/legacy-viper-env.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { Stdin } from "../../../shared/runtime/stdin.service.ts"; @@ -33,8 +34,18 @@ export const legacyLogin = Effect.fn("legacy.login")(function* (flags: LegacyLog const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const profileFlag = yield* LegacyProfileFlag; + const configuredHome = yield* Config.option(Config.string("SUPABASE_HOME")); + const legacyEnv = yield* LegacyViperEnv; + const claudeCode = yield* legacyEnv.get("CLAUDECODE"); + const claudeCodeAlt = yield* legacyEnv.get("CLAUDE_CODE"); - const claudeHint = legacySuggestClaudePlugin({ stdoutIsTty: tty.stdoutIsTty }); + const claudeHint = legacySuggestClaudePlugin({ + stdoutIsTty: tty.stdoutIsTty, + env: { + CLAUDECODE: Option.getOrUndefined(claudeCode), + CLAUDE_CODE: Option.getOrUndefined(claudeCodeAlt), + }, + }); // Mirrors login's `PostRunE` (`cmd/login.go:42-48`): persist the chosen // profile to `<SUPABASE_HOME or ~/.supabase>/profile` on success. The raw @@ -49,7 +60,7 @@ export const legacyLogin = Effect.fn("legacy.login")(function* (flags: LegacyLog onNone: () => undefined, onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), }); - const envProfile = process.env["SUPABASE_PROFILE"]; + const envProfile = Option.getOrUndefined(yield* Config.option(Config.string("SUPABASE_PROFILE"))); const profileToken = explicitProfileFlag !== undefined ? explicitProfileFlag @@ -61,7 +72,13 @@ export const legacyLogin = Effect.fn("legacy.login")(function* (flags: LegacyLog const saveProfileName = profileToken === undefined ? Effect.void - : saveLegacyProfileName(fs, path, runtimeInfo.homeDir, profileToken); + : saveLegacyProfileName( + fs, + path, + runtimeInfo.homeDir, + profileToken, + Option.getOrUndefined(configuredHome), + ); const tokenPath = (token: string) => Effect.gen(function* () { @@ -113,9 +130,7 @@ const resolveToken = Effect.fnUntraced(function* (flags: LegacyLoginFlags) { if (!stdin.isTTY) { const piped = yield* stdin.readPipedText; if (Option.isSome(piped)) return Option.some(piped.value); - return yield* Effect.fail( - new LegacyLoginMissingTokenError({ message: LEGACY_LOGIN_MISSING_TOKEN_MESSAGE }), - ); + return yield* new LegacyLoginMissingTokenError({ message: LEGACY_LOGIN_MISSING_TOKEN_MESSAGE }); } return Option.none<string>(); }); diff --git a/apps/cli/src/legacy/commands/login/login.integration.test.ts b/apps/cli/src/legacy/commands/login/login.integration.test.ts index 14de42826a..873df9b947 100644 --- a/apps/cli/src/legacy/commands/login/login.integration.test.ts +++ b/apps/cli/src/legacy/commands/login/login.integration.test.ts @@ -1,8 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import { @@ -31,6 +29,9 @@ import { legacyLogin } from "./login.handler.ts"; import type { LegacyLoginFlags } from "./login.command.ts"; const tempRoot = useLegacyTempWorkdir("supabase-login-int-"); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const join = (first: string, ...rest: ReadonlyArray<string>) => path.join(first, ...rest); const noopHttpClient = Layer.succeed( HttpClient.HttpClient, @@ -55,6 +56,7 @@ interface SetupOpts { readonly homeDir?: string; /** Raw argv for explicit `--profile` detection. */ readonly argv?: ReadonlyArray<string>; + readonly env?: Record<string, string>; } function flags(overrides: Partial<LegacyLoginFlags> = {}): LegacyLoginFlags { @@ -104,6 +106,7 @@ function setupLegacyLogin(opts: SetupOpts = {}) { ...(opts.homeDir !== undefined ? { runtimeInfo: mockRuntimeInfo({ homeDir: opts.homeDir }) } : {}), + env: opts.env, }), credentials.layer, crypto.layer, @@ -152,7 +155,7 @@ describe("legacy login integration", () => { const exit = yield* Effect.exit(legacyLogin(flags({ token: Option.some("not-a-token") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = encodeJson(exit.cause); expect(json).toContain("LegacyLoginSaveTokenError"); expect(json).toContain("cannot save provided token:"); } @@ -165,7 +168,7 @@ describe("legacy login integration", () => { const exit = yield* Effect.exit(legacyLogin(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = encodeJson(exit.cause); expect(json).toContain("LegacyLoginMissingTokenError"); expect(json).toContain("Cannot use automatic login flow inside non-TTY environments"); } @@ -222,7 +225,7 @@ describe("legacy login integration", () => { const exit = yield* Effect.exit(legacyLogin(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLoginFailedError"); + expect(encodeJson(exit.cause)).toContain("LegacyLoginFailedError"); } // The 3rd (final) failure gives up without printing a Retry notice. expect(out.stderrText).toContain("Retry (2/2): "); @@ -236,7 +239,7 @@ describe("legacy login integration", () => { const exit = yield* Effect.exit(legacyLogin(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = encodeJson(exit.cause); expect(json).toContain("LegacyLoginDecryptError"); expect(json).toContain("cannot decrypt access token"); } @@ -293,7 +296,7 @@ describe("legacy login integration", () => { const exit = yield* Effect.exit(legacyLogin(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLoginCryptoError"); + expect(encodeJson(exit.cause)).toContain("LegacyLoginCryptoError"); } }).pipe(Effect.provide(layer)); }); @@ -314,21 +317,11 @@ describe("legacy login integration", () => { it.live( "prints the Claude Code plugin hint to stderr when in Claude Code with a TTY stdout", () => { - const prev = process.env["CLAUDECODE"]; - process.env["CLAUDECODE"] = "1"; - const { layer, out } = setupLegacyLogin({ stdoutIsTty: true }); + const { layer, out } = setupLegacyLogin({ stdoutIsTty: true, env: { CLAUDECODE: "1" } }); return Effect.gen(function* () { yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); expect(out.stderrText).toContain("claude-code-hint"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["CLAUDECODE"]; - else process.env["CLAUDECODE"] = prev; - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); @@ -340,45 +333,41 @@ describe("legacy login integration", () => { return Effect.gen(function* () { yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); const profilePath = join(tempRoot.current, ".supabase", "profile"); - expect(existsSync(profilePath)).toBe(true); - expect(readFileSync(profilePath, "utf8")).toBe("supabase-staging"); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(profilePath)).toBe(true); + expect(yield* fs.readFileString(profilePath)).toBe("supabase-staging"); }).pipe(Effect.provide(layer)); }); // The shadowed env value must never be re-persisted (Go: pflag `Changed`). it.live("explicit --profile supabase persists 'supabase', shadowing SUPABASE_PROFILE", () => { - const prev = process.env["SUPABASE_PROFILE"]; - process.env["SUPABASE_PROFILE"] = "rogue-profile"; const { layer } = setupLegacyLogin({ argv: ["login", "--profile", "supabase", "--token", LEGACY_VALID_TOKEN], homeDir: tempRoot.current, + env: { SUPABASE_PROFILE: "rogue-profile" }, }); return Effect.gen(function* () { yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); const profilePath = join(tempRoot.current, ".supabase", "profile"); - expect(readFileSync(profilePath, "utf8")).toBe("supabase"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_PROFILE"]; - else process.env["SUPABASE_PROFILE"] = prev; - }), - ), - ); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.readFileString(profilePath)).toBe("supabase"); + }).pipe(Effect.provide(layer)); }); // Permanently heals a file persisted by an older lenient version (#6091). it.live("explicit --profile supabase heals a stale persisted profile file", () => { - mkdirSync(join(tempRoot.current, ".supabase"), { recursive: true }); - writeFileSync(join(tempRoot.current, ".supabase", "profile"), "resms"); const { layer } = setupLegacyLogin({ argv: ["login", "--profile=supabase"], homeDir: tempRoot.current, }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, ".supabase"), { recursive: true }); + yield* fs.writeFileString(join(tempRoot.current, ".supabase", "profile"), "resms"); yield* legacyLogin(flags({ token: Option.some(LEGACY_VALID_TOKEN) })); - expect(readFileSync(join(tempRoot.current, ".supabase", "profile"), "utf8")).toBe("supabase"); + expect(yield* fs.readFileString(join(tempRoot.current, ".supabase", "profile"))).toBe( + "supabase", + ); }).pipe(Effect.provide(layer)); }); @@ -388,7 +377,7 @@ describe("legacy login integration", () => { const exit = yield* Effect.exit(legacyLogin(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + expect(encodeJson(exit.cause)).toContain("NonInteractiveError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts index 9dd870f03f..9f693cf916 100644 --- a/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- legacy e2e exercises the subprocess and temporary filesystem boundary directly. import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/logout/logout.handler.ts b/apps/cli/src/legacy/commands/logout/logout.handler.ts index b1eb22434d..a912158bb9 100644 --- a/apps/cli/src/legacy/commands/logout/logout.handler.ts +++ b/apps/cli/src/legacy/commands/logout/logout.handler.ts @@ -39,9 +39,7 @@ export const legacyLogout = Effect.fn("legacy.logout")(function* () { return yield* legacyPromptYesNo(output, yes, confirmLabel, false); }); if (!confirmed) { - return yield* Effect.fail( - new LegacyLogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyLogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } // Delete the access token. `LegacyNotLoggedInError` is the not-logged-in diff --git a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts index b19fbc54fd..3155446eaf 100644 --- a/apps/cli/src/legacy/commands/logout/logout.integration.test.ts +++ b/apps/cli/src/legacy/commands/logout/logout.integration.test.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { ConfigProvider, Effect, Exit, Formatter, Layer } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { mockLegacyCredentialsTracked, mockLegacyTelemetryStateTracked, } from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyYesFlag } from "../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyLogout } from "./logout.handler.ts"; interface SetupOpts { @@ -20,9 +22,18 @@ interface SetupOpts { readonly stdinIsTty?: boolean; /** Piped (non-TTY) stdin answers, one consumed per confirmation prompt. */ readonly pipedAnswers?: ReadonlyArray<string>; + readonly env?: Readonly<Record<string, string | undefined>>; } function setupLegacyLogout(opts: SetupOpts = {}) { + const env: Record<string, string> = {}; + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (value !== undefined) env[key] = value; + } + const configProvider = ConfigProvider.fromEnv({ + env, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: opts.format ?? "text", confirmLogout: opts.confirm ?? false, @@ -31,6 +42,9 @@ function setupLegacyLogout(opts: SetupOpts = {}) { const telemetry = mockLegacyTelemetryStateTracked(); const credentials = mockLegacyCredentialsTracked({ deleteOutcome: opts.deleteOutcome ?? "ok" }); const layer = Layer.mergeAll( + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), + processEnvLayer(opts.env), out.layer, credentials.layer, telemetry.layer, @@ -79,7 +93,7 @@ describe("legacy logout integration", () => { const exit = yield* Effect.exit(legacyLogout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLogoutCancelledError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyLogoutCancelledError"); } expect(credentials.deletedAll).toBe(false); }).pipe(Effect.provide(layer)); @@ -94,7 +108,7 @@ describe("legacy logout integration", () => { const exit = yield* Effect.exit(legacyLogout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyLogoutCancelledError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyLogoutCancelledError"); } expect(credentials.deletedAll).toBe(false); }).pipe(Effect.provide(layer)); @@ -115,21 +129,15 @@ describe("legacy logout integration", () => { // scanning stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase // logout` auto-confirms and deletes rather than consuming the piped `n`. The // handler resolves `yes` via legacyResolveYes, not the raw --yes flag. - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const { layer, credentials } = setupLegacyLogout({ stdinIsTty: false, pipedAnswers: ["n"] }); + const { layer, credentials } = setupLegacyLogout({ + stdinIsTty: false, + pipedAnswers: ["n"], + env: { SUPABASE_YES: "1" }, + }); return Effect.gen(function* () { yield* legacyLogout(); expect(credentials.deletedAll).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); it.live("not logged in: prints to stderr, exits 0, and does not sweep credentials", () => { @@ -151,7 +159,7 @@ describe("legacy logout integration", () => { const exit = yield* Effect.exit(legacyLogout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDeleteTokenError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDeleteTokenError"); } expect(credentials.deletedAll).toBe(false); }).pipe(Effect.provide(layer)); @@ -223,7 +231,7 @@ describe("legacy logout integration", () => { const exit = yield* Effect.exit(legacyLogout()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("NonInteractiveError"); + expect(Formatter.formatJson(exit.cause)).toContain("NonInteractiveError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/migration/down/down.handler.ts b/apps/cli/src/legacy/commands/migration/down/down.handler.ts index c151db85ff..c9bdbbdf4a 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.handler.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.handler.ts @@ -49,15 +49,15 @@ const runDown = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; // Flag-group mutual-exclusion first: validated at // parse time, ahead of the root pre-run. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } const connType = target.connType ?? "local"; // down defaults to `--local`. @@ -66,12 +66,10 @@ const runDown = Effect.fnUntraced(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // Resolve the DB config BEFORE the `--last` validation, so an unlinked/invalid @@ -94,22 +92,14 @@ const runDown = Effect.fnUntraced(function* ( // on the handler's own failure. Load it now and attach the // cache to the whole flow via `Effect.ensuring`, so it runs even on the `--last`/cancel // failure paths. - const cacheLinkedRef = - connType === "linked" - ? yield* Effect.gen(function* () { - const projectRef = yield* LegacyProjectRefResolver; - const linkedProjectCache = yield* LegacyLinkedProjectCache; - const linkedRef = yield* projectRef.loadProjectRef(flags.projectRef); - return linkedProjectCache.cache(linkedRef); - }) - : undefined; + const linkedRef = + connType === "linked" ? yield* projectRef.loadProjectRef(flags.projectRef) : undefined; + const cacheLinkedRef = linkedRef === undefined ? undefined : linkedProjectCache.cache(linkedRef); const downFlow = Effect.gen(function* () { // `--last` zero-value validation runs after DB-config resolution. if (flags.last === 0) { - return yield* Effect.fail( - new LegacyMigrationLastZeroError({ message: "--last must be greater than 0" }), - ); + return yield* new LegacyMigrationLastZeroError({ message: "--last must be greater than 0" }); } const ref = Option.getOrUndefined(cfg.ref ?? Option.none()); @@ -131,12 +121,10 @@ const runDown = Effect.fnUntraced(function* ( const remote = yield* legacyListRemoteMigrations(session); const total = remote.length; if (total <= flags.last) { - return yield* Effect.fail( - new LegacyMigrationLastTooLargeError({ - message: `--last must be smaller than total applied migrations: ${total}`, - suggestion: `Try ${legacyAqua("supabase db reset")} if you want to revert all migrations.`, - }), - ); + return yield* new LegacyMigrationLastTooLargeError({ + message: `--last must be smaller than total applied migrations: ${total}`, + suggestion: `Try ${legacyAqua("supabase db reset")} if you want to revert all migrations.`, + }); } const confirmed = yield* legacyMigrationConfirm( @@ -147,9 +135,7 @@ const runDown = Effect.fnUntraced(function* ( }, ); if (!confirmed) { - return yield* Effect.fail( - new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } const version = remote[total - flags.last - 1]!; @@ -157,6 +143,7 @@ const runDown = Effect.fnUntraced(function* ( yield* legacyDropUserSchemas(session); yield* legacyUpsertVaultSecrets(session, toml.vault); yield* legacyMigrateAndSeed(session, fs, path, cliConfig.workdir, version, { + projectEnv: toml.projectEnv, migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, // `version` is always non-empty here (`migration down` reverts to a concrete diff --git a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts index 6f1acb1bca..c73b632796 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts @@ -1,9 +1,17 @@ import { createHash } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, +} from "effect"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { @@ -17,6 +25,7 @@ import { import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; @@ -54,7 +63,36 @@ interface SetupOpts { const SELECT_SEED = "SELECT path, hash FROM supabase_migrations.seed_files"; +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingDirectories: string[] = []; +const pendingWrites: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; +const mkdirSync = (path: string, _options?: { readonly recursive?: boolean }) => { + pendingDirectories.push(path); +}; +const writeFileSync = (path: string, contents: string | Uint8Array) => { + pendingWrites.push({ path, contents }); +}; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const directory of pendingDirectories) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString( + write.path, + typeof write.contents === "string" + ? write.contents + : new TextDecoder().decode(write.contents), + ); + } + pendingDirectories.length = 0; + pendingWrites.length = 0; +}); + function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); if (opts.config !== undefined) { mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync(join(workdir, "supabase", "config.toml"), opts.config); @@ -139,6 +177,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const layer = Layer.mergeAll( out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), telemetry.layer, cache.layer, resolver, @@ -155,6 +195,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // supplied via piped stdin rather than the Output prompt mock. opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), ), + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), BunServices.layer, ); return { layer, out, telemetry, execs, queries, cache }; diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts index ade33a8ee1..5df10a1094 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.e2e.test.ts @@ -1,26 +1,33 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { beforeEach, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; +import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase migration fetch (legacy)", () => { - let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-mig-fetch-e2e-")); - mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); - writeFileSync( - join(workdir, "supabase", "migrations", "20240101000000_existing.sql"), - "select 1;\n", - ); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); + const workdir = useLegacyTempWorkdir("sb-mig-fetch-e2e-"); + beforeEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir.current, "supabase", "migrations"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workdir.current, "supabase", "config.toml"), + "[db]\nport = 54322\n", + ); + yield* fs.writeFileString( + path.join(workdir.current, "supabase", "migrations", "20240101000000_existing.sql"), + "select 1;\n", + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); // Real-subprocess guard for the production Stdin wiring + confirm prompt: a piped // answer to the overwrite prompt must actually be read, not auto-defaulted. A declined @@ -30,25 +37,30 @@ describe("supabase migration fetch (legacy)", () => { test( "reads a piped 'n' answer to the overwrite prompt and cancels", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase(["migration", "fetch", "--local"], { + () => + runSupabase(["migration", "fetch", "--local"], { entrypoint: "legacy", - cwd: workdir, + cwd: workdir.current, stdin: "n\n", - }); - - // Declined → cancelled (exit 1), and the prompt label reached stderr. - expect(exitCode).toBe(1); - expect(stripAnsi(stderr)).toContain("[Y/n]"); - // A declined prompt renders a lone `context canceled` line, with NO - // `SuggestDebugFlag` troubleshooting hint appended. CLI-1973. - const lines = stripAnsi(stderr).trimEnd().split("\n"); - expect(lines.at(-1)).toBe("context canceled"); - expect(stderr).not.toContain("Try rerunning the command with --debug"); - // The existing file was NOT overwritten — the piped answer was honored. - expect(readdirSync(join(workdir, "supabase", "migrations"))).toEqual([ - "20240101000000_existing.sql", - ]); - }, + }).then(({ exitCode, stderr }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Declined → cancelled (exit 1), and the prompt label reached stderr. + expect(exitCode).toBe(1); + expect(stripAnsi(stderr)).toContain("[Y/n]"); + // A declined prompt renders a lone `context canceled` line, with NO + // `SuggestDebugFlag` troubleshooting hint appended. CLI-1973. + const lines = stripAnsi(stderr).trimEnd().split("\n"); + expect(lines.at(-1)).toBe("context canceled"); + expect(stderr).not.toContain("Try rerunning the command with --debug"); + // The existing file was NOT overwritten — the piped answer was honored. + expect( + yield* fs.readDirectory(path.join(workdir.current, "supabase", "migrations")), + ).toEqual(["20240101000000_existing.sql"]); + }).pipe(Effect.provide(BunServices.layer)), + ), + ), ); }); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts index 1ed20097cf..b34afc101c 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.handler.ts @@ -36,16 +36,16 @@ const runFetch = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; // Flag-group mutual-exclusion first: cobra's `MarkFlagsMutuallyExclusive` validates at // parse time, ahead of the root `PersistentPreRunE` (same ordering as `migration down`/ // `repair`). if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } const connType = target.connType ?? "linked"; // fetch defaults to `--linked`. @@ -54,12 +54,10 @@ const runFetch = Effect.fnUntraced(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // Resolve the DB config BEFORE any filesystem/prompt side effects — an invalid @@ -83,15 +81,9 @@ const runFetch = Effect.fnUntraced(function* ( // Linked fetch caches the project ref on success. The ref is // loaded now (pre-run), but the cache write is attached to the body via `Effect.ensuring`, // so a declined prompt returns before it runs. - const cacheLinkedRef = - connType === "linked" - ? yield* Effect.gen(function* () { - const projectRef = yield* LegacyProjectRefResolver; - const linkedProjectCache = yield* LegacyLinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(flags.projectRef); - return linkedProjectCache.cache(ref); - }) - : undefined; + const linkedRef = + connType === "linked" ? yield* projectRef.loadProjectRef(flags.projectRef) : undefined; + const cacheLinkedRef = linkedRef === undefined ? undefined : linkedProjectCache.cache(linkedRef); const fetchBody = Effect.gen(function* () { const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); @@ -123,9 +115,7 @@ const runFetch = Effect.fnUntraced(function* ( const title = `Do you want to overwrite existing files in ${legacyBold("supabase/migrations")} directory?`; const overwrite = yield* legacyMigrationConfirm(title, { defaultValue: true, yes }); if (!overwrite) { - return yield* Effect.fail( - new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } } @@ -156,11 +146,9 @@ const runFetch = Effect.fnUntraced(function* ( const escapes = (segment: string) => /[/\\]/u.test(segment) || segment.split(/[/\\]/u).includes(".."); if (escapes(file.version) || escapes(file.name)) { - return yield* Effect.fail( - new LegacyMigrationFetchWriteError({ - message: `failed to write migration: invalid version/name in history table: ${file.version}_${file.name}`, - }), - ); + return yield* new LegacyMigrationFetchWriteError({ + message: `failed to write migration: invalid version/name in history table: ${file.version}_${file.name}`, + }); } const name = `${file.version}_${file.name}.sql`; const filePath = path.join(migrationsDir, name); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts index 1af8b5b187..c605c6dbb7 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -1,8 +1,16 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, +} from "effect"; import { LEGACY_VALID_REF, @@ -14,6 +22,7 @@ import { import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; @@ -29,6 +38,49 @@ import type { LegacyMigrationFetchFlags } from "./fetch.command.ts"; const SELECT_SQL = "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations"; +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingDirectories: string[] = []; +const pendingWrites: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; +const mkdirSync = (path: string, _options?: { readonly recursive?: boolean }) => { + pendingDirectories.push(path); +}; +const writeFileSync = (path: string, contents: string | Uint8Array) => { + pendingWrites.push({ path, contents }); +}; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const directory of pendingDirectories) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString( + write.path, + typeof write.contents === "string" + ? write.contents + : new TextDecoder().decode(write.contents), + ); + } + pendingDirectories.length = 0; + pendingWrites.length = 0; +}); +const readDirectory = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(path); + }); +const readText = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }); +const exists = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }); + interface MigrationRow { readonly version: string; readonly name: string; @@ -48,6 +100,7 @@ interface SetupOpts { } function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], @@ -110,6 +163,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const layer = Layer.mergeAll( out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), telemetry.layer, cache.layer, resolver, @@ -126,6 +181,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // supplied via piped stdin rather than the Output prompt mock. opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), ), + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), BunServices.layer, ); return { layer, out, telemetry, cache }; @@ -157,9 +213,9 @@ describe("legacy migration fetch", () => { // The connection banner prints to stderr before dialing. expect(out.stderrText).toContain("Connecting to remote database..."); const dir = migrationsDir(tmp.current); - const files = readdirSync(dir); + const files = yield* readDirectory(dir); expect(files).toEqual(["20240101000000_init.sql"]); - expect(readFileSync(join(dir, files[0]!), "utf8")).toBe("create table a;\ncreate index b;\n"); + expect(yield* readText(join(dir, files[0]!))).toBe("create table a;\ncreate index b;\n"); }).pipe(Effect.provide(layer)); }); @@ -175,7 +231,7 @@ describe("legacy migration fetch", () => { return Effect.gen(function* () { yield* legacyMigrationFetch(flags()); const dir = migrationsDir(tmp.current); - expect(readFileSync(join(dir, "20240101000000_empty.sql"), "utf8")).toBe(";\n"); + expect(yield* readText(join(dir, "20240101000000_empty.sql"))).toBe(";\n"); }).pipe(Effect.provide(layer)); }); @@ -188,7 +244,7 @@ describe("legacy migration fetch", () => { }); return Effect.gen(function* () { yield* legacyMigrationFetch(flags()); - expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + expect(yield* readDirectory(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); }).pipe(Effect.provide(layer)); }); @@ -206,7 +262,7 @@ describe("legacy migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); @@ -228,7 +284,7 @@ describe("legacy migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); @@ -242,7 +298,7 @@ describe("legacy migration fetch", () => { return Effect.gen(function* () { yield* legacyMigrationFetch(flags()); expect(out.stderrText).toContain("[Y/n] y"); - expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + expect(yield* readDirectory(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); }).pipe(Effect.provide(layer)); }); @@ -261,7 +317,9 @@ describe("legacy migration fetch", () => { return Effect.gen(function* () { yield* legacyMigrationFetch(flags()); expect(out.stderrText).toContain("[Y/n] y"); - expect(readdirSync(migrationsDir(tmp.current))).toContain("20240101000000_init.sql"); + expect(yield* readDirectory(migrationsDir(tmp.current))).toContain( + "20240101000000_init.sql", + ); }).pipe(Effect.provide(layer)); }, ); @@ -308,7 +366,7 @@ describe("legacy migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyOperationCanceledError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["existing.sql"]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual(["existing.sql"]); }).pipe(Effect.provide(layer)); }); @@ -326,7 +384,7 @@ describe("legacy migration fetch", () => { expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyMigrationFetchWriteError"); } // Nothing is written when the guard fires. - expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -339,7 +397,7 @@ describe("legacy migration fetch", () => { }); return Effect.gen(function* () { yield* legacyMigrationFetch(flags()); - expect(readdirSync(migrationsDir(tmp.current))).toEqual(["-1_legacy.sql"]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual(["-1_legacy.sql"]); }).pipe(Effect.provide(layer)); }); @@ -356,7 +414,7 @@ describe("legacy migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyMigrationFetchWriteError"); } - expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -391,7 +449,7 @@ describe("legacy migration fetch", () => { expect(Option.isSome(failure) && failure.value._tag).toBe("LegacyDbConfigLoadError"); } // The config failed before any side effect: no migrations dir, no overwrite prompt. - expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(yield* exists(migrationsDir(tmp.current))).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); @@ -459,7 +517,7 @@ describe("legacy migration fetch", () => { "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } - expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(yield* exists(migrationsDir(tmp.current))).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts index 48fa94e903..a3f0d1cced 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/node-builtin-import -- this live test owns temporary host files and unique migration identities. import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; diff --git a/apps/cli/src/legacy/commands/migration/list/list.handler.ts b/apps/cli/src/legacy/commands/migration/list/list.handler.ts index f318a4b791..720f55f140 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.handler.ts @@ -40,31 +40,25 @@ const runList = Effect.fnUntraced(function* ( // first, then {db-url, password}. `setFlags` is already // alphabetically sorted, matching the established group-error formatting. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { - return yield* Effect.fail( - new LegacyMigrationPasswordFlagsError({ - message: - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - }), - ); + return yield* new LegacyMigrationPasswordFlagsError({ + message: + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + }); } // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && (target.connType ?? "linked") !== "linked") { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const listBody = Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts index c5eccee980..7b30add499 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts @@ -1,8 +1,6 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, ManagedRuntime, Option, Path } from "effect"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { @@ -30,6 +28,34 @@ import type { LegacyMigrationListFlags } from "./list.command.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingDirectories: string[] = []; +const pendingWrites: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; +const mkdirSync = (path: string, _options?: { readonly recursive?: boolean }) => { + pendingDirectories.push(path); +}; +const writeFileSync = (path: string, contents: string | Uint8Array) => { + pendingWrites.push({ path, contents }); +}; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const directory of pendingDirectories) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString( + write.path, + typeof write.contents === "string" + ? write.contents + : new TextDecoder().decode(write.contents), + ); + } + pendingDirectories.length = 0; + pendingWrites.length = 0; +}); + interface SetupOpts { readonly format?: OutputFormat; readonly args?: ReadonlyArray<string>; @@ -105,6 +131,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockLegacyCliConfig({ workdir }), Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), BunServices.layer, ); return { diff --git a/apps/cli/src/legacy/commands/migration/list/list.live.test.ts b/apps/cli/src/legacy/commands/migration/list/list.live.test.ts index d2cc786163..09fc90132f 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { expect } from "vitest"; import { test } from "../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts index dc67ea14fa..fdaac6a8d6 100644 --- a/apps/cli/src/legacy/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/migration.integration.test.ts @@ -1,10 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer } from "effect"; +import * as Formatter from "effect/Formatter"; import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; import { LEGACY_GLOBAL_FLAGS } from "../../../shared/legacy/global-flags.ts"; import { legacyMigrationCommand } from "./migration.command.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, +} from "../../../../tests/helpers/legacy-mocks.ts"; +import { mockOutput, mockTelemetryRuntime } from "../../../../tests/helpers/mocks.ts"; // `withGlobalFlags` must come AFTER `withSubcommands` — see // `start.string-slice-flags.integration.test.ts`'s identical comment. @@ -27,15 +35,26 @@ describe("legacy migration command integration", () => { ]).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeJson = JSON.stringify(exit.cause); + const causeJson = Formatter.formatJson(exit.cause); // The alias resolved: the parse error is scoped to the squash LEAF, not the root. expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]'); expect(causeJson).not.toContain('"subcommand":"migrations"'); } - }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); + }).pipe( + Effect.provide( + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + buildLegacyTestRuntime({ + out: mockOutput(), + api: mockLegacyPlatformApi(), + cliConfig: mockLegacyCliConfig({ workdir: "." }), + }), + mockTelemetryRuntime(), + BunServices.layer, + ), + ), + ); - // Command.runWith's Environment type is retained even though this path only needs CliOutput - // at runtime. - return run as Effect.Effect<void>; + return run; }); }); diff --git a/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts b/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts index 08270e5b37..a1f27cc705 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.e2e.test.ts @@ -1,39 +1,40 @@ -import { mkdtempSync, readdirSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; +import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase migration new (legacy)", () => { - let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-mig-new-e2e-")); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); + const workdir = useLegacyTempWorkdir("sb-mig-new-e2e-"); // Primary golden path: a real subprocess creates the migration file under the // working directory and prints the workdir-relative path. No infra required. test( "creates a timestamped migration file and prints its path", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["migration", "new", "create_widgets"], { + () => + runSupabase(["migration", "new", "create_widgets"], { entrypoint: "legacy", - cwd: workdir, - }); - - expect(exitCode).toBe(0); - const files = readdirSync(join(workdir, "supabase", "migrations")); - expect(files).toHaveLength(1); - expect(files[0]).toMatch(/^\d{14}_create_widgets\.sql$/u); - expect(stripAnsi(stdout)).toContain( - `Created new migration at supabase/migrations/${files[0]}`, - ); - }, + cwd: workdir.current, + }).then(({ exitCode, stdout }) => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + expect(exitCode).toBe(0); + const files = yield* fs.readDirectory( + path.join(workdir.current, "supabase", "migrations"), + ); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/^\d{14}_create_widgets\.sql$/u); + expect(stripAnsi(stdout)).toContain( + `Created new migration at supabase/migrations/${files[0]}`, + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ), ); }); diff --git a/apps/cli/src/legacy/commands/migration/new/new.handler.ts b/apps/cli/src/legacy/commands/migration/new/new.handler.ts index 178634c053..375afd21a4 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.handler.ts @@ -45,11 +45,9 @@ export const legacyMigrationNew = Effect.fn("legacy.migration.new")(function* ( // vector — the same TS-only hardening `migration fetch` applies to remote rows. const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); if (!migrationPath.startsWith(migrationsDir + path.sep)) { - return yield* Effect.fail( - new LegacyMigrationNewWriteError({ - message: `invalid migration name: "${flags.migrationName}" must not escape the ${path.join("supabase", "migrations")} directory`, - }), - ); + return yield* new LegacyMigrationNewWriteError({ + message: `invalid migration name: "${flags.migrationName}" must not escape the ${path.join("supabase", "migrations")} directory`, + }); } yield* fs diff --git a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts index 5f61311797..cb7ee41a0f 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.integration.test.ts @@ -1,8 +1,16 @@ -import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Stream } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Stream, +} from "effect"; import { badArgument } from "effect/PlatformError"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; @@ -25,6 +33,26 @@ interface SetupOpts { readonly writeDoesNotMaterialize?: boolean; } +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingWrites: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; +const writeFileSync = (path: string, contents: string | Uint8Array) => { + pendingWrites.push({ path, contents }); +}; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString( + write.path, + typeof write.contents === "string" + ? write.contents + : new TextDecoder().decode(write.contents), + ); + } + pendingWrites.length = 0; +}); + function nonMaterializingFsLayer( workdir: string, opts: Pick<SetupOpts, "openDoesNotMaterialize" | "writeDoesNotMaterialize">, @@ -53,6 +81,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { telemetry.layer, mockStdin(opts.isTTY ?? true, opts.piped), mockLegacyCliConfig({ workdir }), + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), BunServices.layer, ...(opts.openDoesNotMaterialize === true || opts.writeDoesNotMaterialize === true ? [nonMaterializingFsLayer(workdir, opts)] @@ -64,11 +93,28 @@ function setup(workdir: string, opts: SetupOpts = {}) { const tmp = useLegacyTempWorkdir(); const migrationsDir = (workdir: string) => join(workdir, "supabase", "migrations"); -const onlyMigration = (workdir: string) => { - const files = readdirSync(migrationsDir(workdir)); - expect(files).toHaveLength(1); - return files[0]!; -}; +const onlyMigration = (workdir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* fs.readDirectory(migrationsDir(workdir)); + expect(files).toHaveLength(1); + return files[0]!; + }); +const readText = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }); +const readDirectory = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(path); + }); +const exists = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + }); describe("legacy migration new", () => { it.live("creates a timestamped migration file and prints its relative path", () => { @@ -76,10 +122,10 @@ describe("legacy migration new", () => { return Effect.gen(function* () { yield* legacyMigrationNew({ migrationName: "create_widgets" }); - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); expect(file).toMatch(/^\d{14}_create_widgets\.sql$/u); // Empty file when stdin is a TTY (nothing is written). - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); + expect(yield* readText(join(migrationsDir(tmp.current), file))).toBe(""); // The workdir-relative path prints, not the absolute write path. expect(stripAnsi(out.stdoutText)).toBe( `Created new migration at supabase/migrations/${file}\n`, @@ -94,9 +140,9 @@ describe("legacy migration new", () => { return Effect.gen(function* () { yield* legacyMigrationNew({ migrationName: "from_stdin" }); - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); // Byte-exact: the trailing newline is preserved (raw stdin bytes are copied verbatim). - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(script); + expect(yield* readText(join(migrationsDir(tmp.current), file))).toBe(script); expect(stripAnsi(out.stdoutText)).toContain(`Created new migration at supabase/migrations/`); }).pipe(Effect.provide(layer)); }); @@ -105,8 +151,8 @@ describe("legacy migration new", () => { const { layer } = setup(tmp.current, { isTTY: false }); return Effect.gen(function* () { yield* legacyMigrationNew({ migrationName: "empty_pipe" }); - const file = onlyMigration(tmp.current); - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); + const file = yield* onlyMigration(tmp.current); + expect(yield* readText(join(migrationsDir(tmp.current), file))).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -114,8 +160,8 @@ describe("legacy migration new", () => { const { layer } = setup(tmp.current, { openDoesNotMaterialize: true }); return Effect.gen(function* () { yield* legacyMigrationNew({ migrationName: "windows_open" }); - const file = onlyMigration(tmp.current); - expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); + const file = yield* onlyMigration(tmp.current); + expect(yield* readText(join(migrationsDir(tmp.current), file))).toBe(""); }).pipe(Effect.provide(layer)); }); @@ -137,7 +183,7 @@ describe("legacy migration new", () => { } } } - expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); + expect(yield* readDirectory(migrationsDir(tmp.current))).toEqual([]); expect(out.stdoutText).toBe(""); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -148,7 +194,7 @@ describe("legacy migration new", () => { return Effect.gen(function* () { yield* legacyMigrationNew({ migrationName: "as_json" }); - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); expect(out.stdoutText).toBe(""); expect(out.messages).toContainEqual( expect.objectContaining({ @@ -184,7 +230,7 @@ describe("legacy migration new", () => { expect(failure.value).toBeInstanceOf(LegacyMigrationNewWriteError); } } - expect(existsSync(migrationsDir(tmp.current))).toBe(false); + expect(yield* exists(migrationsDir(tmp.current))).toBe(false); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -203,7 +249,7 @@ describe("legacy migration new", () => { expect(failure.value).toBeInstanceOf(LegacyMigrationNewWriteError); } } - expect(existsSync(join(tmp.current, "supabase"))).toBe(false); + expect(yield* exists(join(tmp.current, "supabase"))).toBe(false); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -245,7 +291,7 @@ describe("legacy migration new", () => { } } } - const file = onlyMigration(tmp.current); + const file = yield* onlyMigration(tmp.current); expect(stripAnsi(out.stdoutText)).toBe( `Created new migration at supabase/migrations/${file}\n`, ); diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts index 8b81a3e72a..aa56190342 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.handler.ts @@ -71,11 +71,9 @@ const updateMigrationTable = Effect.fnUntraced(function* ( for (const version of versions) { const resolved = yield* legacyResolveMigrationFile(fs, path, migrationsDir, version); if (Option.isNone(resolved)) { - return yield* Effect.fail( - new LegacyMigrationFileNotFoundError({ - message: `glob supabase/migrations/${version}_*.sql: file does not exist`, - }), - ); + return yield* new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }); } appliedFiles.push(yield* legacyReadMigrationFile(fs, path, resolved.value)); } @@ -123,21 +121,19 @@ const runRepair = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const dnsResolver = yield* LegacyDnsResolverFlag; + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } if (Option.isSome(input.dbUrl) && Option.isSome(input.password)) { - return yield* Effect.fail( - new LegacyMigrationPasswordFlagsError({ - message: - "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", - }), - ); + return yield* new LegacyMigrationPasswordFlagsError({ + message: + "if any flags in the group [db-url password] are set none of the others can be; [db-url password] were all set", + }); } const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); @@ -148,12 +144,10 @@ const runRepair = Effect.fnUntraced(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(input.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // Resolve the DB config (and, for the linked default, the project ref) BEFORE the @@ -180,26 +174,18 @@ const runRepair = Effect.fnUntraced(function* ( // (pre-run), and the cache is attached to the whole // repair flow via `Effect.ensuring` below — so it runs even when the version parse fails // or the repair-all prompt is declined (caches on cancellation too). - const cacheLinkedRef = - connType === "linked" - ? yield* Effect.gen(function* () { - const projectRef = yield* LegacyProjectRefResolver; - const linkedProjectCache = yield* LegacyLinkedProjectCache; - const ref = yield* projectRef.loadProjectRef(input.projectRef); - return linkedProjectCache.cache(ref); - }) - : undefined; + const linkedRef = + connType === "linked" ? yield* projectRef.loadProjectRef(input.projectRef) : undefined; + const cacheLinkedRef = linkedRef === undefined ? undefined : linkedProjectCache.cache(linkedRef); const repairFlow = Effect.gen(function* () { // Version validation runs after DB-config resolution. Rejects non-numeric AND // out-of-int64-range values; `legacyParseMigrationVersion` mirrors that exactly. for (const version of input.versions) { if (legacyParseMigrationVersion(version) === undefined) { - return yield* Effect.fail( - new LegacyMigrationInvalidVersionError({ - message: `failed to parse ${version}: invalid version number`, - }), - ); + return yield* new LegacyMigrationInvalidVersionError({ + message: `failed to parse ${version}: invalid version number`, + }); } } @@ -211,9 +197,7 @@ const runRepair = Effect.fnUntraced(function* ( { defaultValue: false, yes }, ); if (!confirmed) { - return yield* Effect.fail( - new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyOperationCanceledError({ message: CONTEXT_CANCELED_MESSAGE }); } versions = yield* legacyLoadLocalVersions(fs, path, migrationsDir); } diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts index 34ed94bb7b..1913c4ccac 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts @@ -1,8 +1,16 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, +} from "effect"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { @@ -16,6 +24,7 @@ import { import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -38,11 +47,16 @@ interface SetupOpts { readonly yes?: boolean; readonly confirm?: boolean; readonly args?: ReadonlyArray<string>; + readonly env?: Readonly<Record<string, string>>; readonly failSql?: string; readonly failResolve?: boolean; } function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm === undefined ? undefined : [opts.confirm], @@ -118,7 +132,10 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const layer = Layer.mergeAll( + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), out.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), telemetry.layer, cache.layer, resolver, @@ -151,11 +168,23 @@ const input = (over: Partial<LegacyMigrationRepairInput> = {}): LegacyMigrationR }); const seedMigration = (workdir: string, name: string, body: string) => { - const dir = join(workdir, "supabase", "migrations"); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, name), body); + pendingWrites.push({ + path: fixturePath.join(workdir, "supabase", "migrations", name), + contents: body, + }); }; +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const pendingWrites: Array<{ readonly path: string; readonly contents: string }> = []; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingWrites.length = 0; +}); + const tmp = useLegacyTempWorkdir(); describe("legacy migration repair", () => { @@ -339,23 +368,13 @@ describe("legacy migration repair", () => { it.live("auto-confirms repair-all via SUPABASE_YES (no --yes flag)", () => { // SUPABASE_YES=1 auto-confirms without --yes. - const previous = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - const { layer, execs, queries } = setup(tmp.current); + const { layer, execs, queries } = setup(tmp.current, { env: { SUPABASE_YES: "1" } }); return Effect.gen(function* () { yield* legacyMigrationRepair(input({ versions: [], status: "applied" })); expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = previous; - }), - ), - ); + }).pipe(Effect.provide(layer)); }); it.live( @@ -364,7 +383,10 @@ describe("legacy migration repair", () => { // SUPABASE_YES set only in supabase/.env (not the shell) — the project env loads it // before the repair-all prompt, so it auto-confirms with no --yes flag and no stdin answer. seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); - writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); + pendingWrites.push({ + path: fixturePath.join(tmp.current, "supabase", ".env"), + contents: "SUPABASE_YES=true\n", + }); const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { yield* legacyMigrationRepair(input({ versions: [], status: "applied" })); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts index 0423b22430..16a75ec7a3 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.diff.unit.test.ts @@ -1,6 +1,6 @@ -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { LEGACY_SQUASH_SEPARATOR_COMMENT, @@ -14,16 +14,25 @@ import { * corpus of 90+109+19 lines is exactly the kind of content a manual transcription * would silently corrupt (trailing whitespace, blank lines, quoting). */ -const testdataDir = fileURLToPath(new URL("./testdata/", import.meta.url)); -const readGoFixture = (name: string) => readFileSync(`${testdataDir}${name}`, "utf8"); +const readGoFixtures = Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const testdataDir = yield* path.fromFileUrl(new URL("./testdata/", import.meta.url)); + return yield* Effect.all({ + before: fs.readFileString(path.join(testdataDir, "before.sql")), + after: fs.readFileString(path.join(testdataDir, "after.sql")), + expected: fs.readFileString(path.join(testdataDir, "diff.sql")), + }); +}); describe("legacySquashLineByLineDiff", () => { - it("diffs real pg_dump output into Go's exact diff.sql bytes", () => { - const before = readGoFixture("before.sql"); - const after = readGoFixture("after.sql"); - const expected = readGoFixture("diff.sql"); - expect(legacySquashLineByLineDiff(before, after)).toBe(expected); - }); + it("diffs real pg_dump output into Go's exact diff.sql bytes", () => + Effect.runPromise( + Effect.gen(function* () { + const { before, after, expected } = yield* readGoFixtures; + expect(legacySquashLineByLineDiff(before, after)).toBe(expected); + }).pipe(Effect.provide(BunServices.layer)), + )); it("keeps only after-only lines when before is shorter", () => { const before = "select 1;"; diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts index ff7824ca40..df4d1c065d 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.dump.ts @@ -53,11 +53,9 @@ export const legacySquashDumpSchema = Effect.fnUntraced(function* <E>( projectEnvValues: params.projectEnvValues, }); if (result.exitCode !== 0) { - return yield* Effect.fail( - new LegacyMigrationSquashDumpError({ - message: `error running container: exit ${result.exitCode}`, - }), - ); + return yield* new LegacyMigrationSquashDumpError({ + message: `error running container: exit ${result.exitCode}`, + }); } }); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts index cc1001d435..7becb70ea6 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.e2e.test.ts @@ -1,22 +1,29 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { beforeEach, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase, stripAnsi } from "../../../../../tests/helpers/cli.ts"; +import { useLegacyTempWorkdir } from "../../../../../tests/helpers/legacy-mocks.ts"; const E2E_TIMEOUT_MS = 30_000; describe("supabase migration squash (legacy)", () => { - let workdir: string; - beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "sb-mig-squash-e2e-")); - mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "[db]\nport = 54322\n"); - }); - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); + const workdir = useLegacyTempWorkdir("sb-mig-squash-e2e-"); + beforeEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir.current, "supabase", "migrations"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workdir.current, "supabase", "config.toml"), + "[db]\nport = 54322\n", + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); // Real-subprocess guard for the production layer graph: `--version 0_init` is // not a valid integer, so the bare `invalid version number` message @@ -30,21 +37,17 @@ describe("supabase migration squash (legacy)", () => { test( "rejects a non-numeric --version with the bare Go message", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stderr } = await runSupabase( - ["migration", "squash", "--version", "0_init"], - { - entrypoint: "legacy", - cwd: workdir, - }, - ); - - expect(exitCode).toBe(1); - const text = stripAnsi(stderr); - expect(text).toContain("invalid version number"); - expect(text).not.toContain("failed to parse"); - expect(text).toContain("Try rerunning the command with --debug to troubleshoot the error."); - }, + () => + runSupabase(["migration", "squash", "--version", "0_init"], { + entrypoint: "legacy", + cwd: workdir.current, + }).then(({ exitCode, stderr }) => { + expect(exitCode).toBe(1); + const text = stripAnsi(stderr); + expect(text).toContain("invalid version number"); + expect(text).not.toContain("failed to parse"); + expect(text).toContain("Try rerunning the command with --debug to troubleshoot the error."); + }), ); // Golden path with no Docker required: a single local migration short-circuits @@ -53,25 +56,32 @@ describe("supabase migration squash (legacy)", () => { test( "no-ops on a single local migration and suggests migration repair", { timeout: E2E_TIMEOUT_MS }, - async () => { - writeFileSync( - join(workdir, "supabase", "migrations", "20240101000000_init.sql"), - "select 1;\n", - ); - - const { exitCode, stdout, stderr } = await runSupabase(["migration", "squash", "--local"], { - entrypoint: "legacy", - cwd: workdir, - }); - - expect(exitCode).toBe(0); - expect(stripAnsi(stderr)).toContain( - "supabase/migrations/20240101000000_init.sql is already the earliest migration.", - ); - expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); - expect(stripAnsi(stderr)).toContain( - "Run supabase migration repair --status applied to update your remote migration history table.", - ); - }, + () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString( + path.join(workdir.current, "supabase", "migrations", "20240101000000_init.sql"), + "select 1;\n", + ); + }).pipe(Effect.provide(BunServices.layer)), + ) + .then(() => + runSupabase(["migration", "squash", "--local"], { + entrypoint: "legacy", + cwd: workdir.current, + }), + ) + .then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(0); + expect(stripAnsi(stderr)).toContain( + "supabase/migrations/20240101000000_init.sql is already the earliest migration.", + ); + expect(stripAnsi(stdout)).toContain("Finished supabase migration squash."); + expect(stripAnsi(stderr)).toContain( + "Run supabase migration repair --status applied to update your remote migration history table.", + ); + }), ); }); diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts index 7d3e85f4e7..42b57fcc42 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.handler.ts @@ -255,9 +255,7 @@ const squashToVersion = Effect.fnUntraced(function* ( const output = yield* Output; const migrations = yield* legacyLoadPartialMigrations(fs, path, migrationsDir, version); if (migrations.length === 0) { - return yield* Effect.fail( - new LegacyMigrationSquashMissingVersionError({ message: "version not found" }), - ); + return yield* new LegacyMigrationSquashMissingVersionError({ message: "version not found" }); } const local = migrations[migrations.length - 1]!; @@ -361,11 +359,9 @@ const baselineMigrations = Effect.fnUntraced(function* ( resolvedVersion, ); if (Option.isNone(resolvedFile)) { - return yield* Effect.fail( - new LegacyMigrationFileNotFoundError({ - message: `glob supabase/migrations/${resolvedVersion}_*.sql: file does not exist`, - }), - ); + return yield* new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${resolvedVersion}_*.sql: file does not exist`, + }); } const m = yield* legacyReadMigrationFile(fs, path, resolvedFile.value); @@ -417,24 +413,14 @@ const runSquash = Effect.fnUntraced(function* ( // 1. Flag groups — parse-time mutual-exclusivity check, ahead of the root // pre-run. if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: cobraMutuallyExclusiveErrorMessage( - ["db-url", "linked", "local"], - target.setFlags, - ), - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(["db-url", "linked", "local"], target.setFlags), + }); } if (Option.isSome(flags.dbUrl) && Option.isSome(flags.password)) { - return yield* Effect.fail( - new LegacyMigrationPasswordFlagsError({ - message: cobraMutuallyExclusiveErrorMessage( - ["db-url", "password"], - ["db-url", "password"], - ), - }), - ); + return yield* new LegacyMigrationPasswordFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(["db-url", "password"], ["db-url", "password"]), + }); } const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); @@ -445,12 +431,10 @@ const runSquash = Effect.fnUntraced(function* ( // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } // 2/3. Linked pre-resolution (mirrors `db diff --linked`, `diff.handler.ts:400-430`): @@ -505,25 +489,36 @@ const runSquash = Effect.fnUntraced(function* ( // before any container starts, and each of squash's three // pg_dump containers resolves its image through the same registry-mirror lookup — // so a dotenv-only mirror override reaches all three dumps below. - yield* legacyApplyProjectEnv(projectEnv); - const yes = yield* legacyResolveYesWithProjectEnv(projectEnv); + const effectiveProjectEnv = { + ...toml.projectEnv, + ...projectEnv, + ...(yield* legacyApplyProjectEnv(projectEnv)), + }; + const effectiveToml = { ...toml, projectEnv: effectiveProjectEnv }; + const effectiveLocalInputs = { + ...localInputs, + context: { + ...localInputs.context, + projectEnvValues: { + ...localInputs.context.projectEnvValues, + ...effectiveProjectEnv, + }, + }, + }; + const yes = yield* legacyResolveYesWithProjectEnv(effectiveProjectEnv); // 7. `--version` validation happens AFTER db-config resolution. const version = Option.getOrElse(flags.version, () => ""); if (version.length > 0) { if (legacyParseMigrationVersion(version) === undefined) { // Bare message — squash does NOT inherit repair's "failed to parse <v>: " prefix. - return yield* Effect.fail( - new LegacyMigrationInvalidVersionError({ message: "invalid version number" }), - ); + return yield* new LegacyMigrationInvalidVersionError({ message: "invalid version number" }); } const versionFile = yield* legacyResolveMigrationFile(fs, path, migrationsDir, version); if (Option.isNone(versionFile)) { - return yield* Effect.fail( - new LegacyMigrationFileNotFoundError({ - message: `glob supabase/migrations/${version}_*.sql: file does not exist`, - }), - ); + return yield* new LegacyMigrationFileNotFoundError({ + message: `glob supabase/migrations/${version}_*.sql: file does not exist`, + }); } } @@ -535,8 +530,8 @@ const runSquash = Effect.fnUntraced(function* ( cliConfig.workdir, migrationsDir, version, - localInputs, - toml, + effectiveLocalInputs, + effectiveToml, ); // 9. Local target: suggest `migration repair` instead of touching the remote history. diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts index 5d1f5dcb75..ff8b17f2fa 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts @@ -1,8 +1,16 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, +} from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -27,6 +35,7 @@ import { import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { LegacyDebugFlag, LegacyDnsResolverFlag, @@ -215,6 +224,37 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingDirectories: string[] = []; +const pendingWrites: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; +const mkdirSync = (_path: string, _options?: { readonly recursive?: boolean }) => { + pendingDirectories.push(_path); +}; +const writeFileSync = (path: string, contents: string | Uint8Array) => { + pendingWrites.push({ path, contents }); +}; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const directory of pendingDirectories) { + yield* fs.makeDirectory(directory, { recursive: true }); + } + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFile( + write.path, + typeof write.contents === "string" + ? new TextEncoder().encode(write.contents) + : write.contents, + ); + } + pendingDirectories.length = 0; + pendingWrites.length = 0; +}); +const existsPath = (path: string) => Effect.flatMap(FileSystem.FileSystem, (fs) => fs.exists(path)); +const readTextPath = (path: string) => + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString(path)); + interface SetupOpts { readonly format?: OutputFormat; readonly isTTY?: boolean; @@ -238,9 +278,14 @@ interface SetupOpts { readonly fullDumpSql?: string; readonly failDumpKind?: "before" | "after" | "full"; readonly fsFaults?: FsFaultOpts; + readonly env?: Readonly<Record<string, string>>; } function setup(workdir: string, opts: SetupOpts = {}) { + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); @@ -355,10 +400,13 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const baseLayer = Layer.mergeAll( + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), // Listed first so every fake service layer below overrides its real // implementation — `Layer.mergeAll` is last-wins on a shared service, // matching `diff.integration.test.ts`'s own established ordering. BunServices.layer, + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, telemetry.layer, cache.layer, @@ -392,6 +440,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { return { layer, + configProvider, out, telemetry, cache, @@ -691,8 +740,8 @@ describe("legacy migration squash", () => { ); const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); - expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + expect(yield* existsPath(join(migrationsDir, "0_init.sql"))).toBe(false); + expect(yield* existsPath(join(migrationsDir, "1_target.sql"))).toBe(true); // Hardcoded (not recomputed via `squash.diff.ts`'s own helpers) so a // regression in the separator constant or the diff algorithm itself @@ -700,7 +749,7 @@ describe("legacy migration squash", () => { // fails this assertion. const expectedTail = "\n--\n-- Dumped schema changes for auth and storage\n--\n\n" + "new auth object;\n"; - expect(readFileSync(join(migrationsDir, "1_target.sql"), "utf8")).toBe( + expect(yield* readTextPath(join(migrationsDir, "1_target.sql"))).toBe( FULL_SQL + expectedTail, ); @@ -743,7 +792,7 @@ describe("legacy migration squash", () => { // `legacyStreamPgDump` applies the registry mirror itself — // the default (no override) registry rewrites // to the ECR mirror, not the bare Dockerfile-manifest tag. - expect(call.image).toBe(legacyGetRegistryImageUrl(dockerfileServiceImage("pg"))); + expect(call.image).toBe(legacyGetRegistryImageUrl(dockerfileServiceImage("pg"), {})); } // Every dump dials the SAME shadow host, whatever this machine's Docker // context resolves it to (`legacyGetHostname`) — self-consistency avoids @@ -804,8 +853,6 @@ describe("legacy migration squash", () => { // the same registry-mirror lookup — so a registry mirror set only in `supabase/.env` // reaches all three. The handler applies that with `legacyApplyProjectEnv`, scoped to // the run and reverted when it completes. - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; const s = setupHappyPath(); writeFileSync( join(tmp.current, "supabase", ".env"), @@ -817,18 +864,7 @@ describe("legacy migration squash", () => { for (const call of s.dumpCalls) { expect(call.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); } - // Reverted once the command's own scope closes (`Effect.scoped` on `runSquash`'s - // terminal pipe) — never leaks into a later command in the same process. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); @@ -838,8 +874,6 @@ describe("legacy migration squash", () => { // Host networking is the default, but an explicit network id // overrides it whenever that resolves non-empty — a value sourced only from // `supabase/.env` still wins over host. - const prev = process.env["SUPABASE_NETWORK_ID"]; - delete process.env["SUPABASE_NETWORK_ID"]; const s = setupHappyPath(); writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_NETWORK_ID=dotenv-net\n"); return Effect.gen(function* () { @@ -848,15 +882,7 @@ describe("legacy migration squash", () => { for (const call of s.dumpCalls) { expect(call.network).toEqual({ _tag: "named", name: "dotenv-net" }); } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_NETWORK_ID"]; - else process.env["SUPABASE_NETWORK_ID"] = prev; - }), - ), - Effect.provide(s.layer), - ); + }).pipe(Effect.provide(s.layer)); }, ); @@ -872,10 +898,10 @@ describe("legacy migration squash", () => { return Effect.gen(function* () { yield* legacyMigrationSquash(flags({ version: Option.some("1") })); const migrationsDir = join(tmp.current, "supabase", "migrations"); - expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); - expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); + expect(yield* existsPath(join(migrationsDir, "0_init.sql"))).toBe(false); + expect(yield* existsPath(join(migrationsDir, "1_target.sql"))).toBe(true); // The newer file was never touched — outside the `--version 1` window. - expect(readFileSync(join(migrationsDir, "2_after.sql"), "utf8")).toBe( + expect(yield* readTextPath(join(migrationsDir, "2_after.sql"))).toBe( "create table c (id int);\n", ); }).pipe(Effect.provide(s.layer)); @@ -991,7 +1017,7 @@ describe("legacy migration squash", () => { // Truncated (by the earlier `O_TRUNC`), then only the partial stream the // dying container managed to write before failing — no separator/diff // was ever appended, since the whole operation aborted first. - expect(readFileSync(targetPath, "utf8")).toBe("partial output before the container died"); + expect(yield* readTextPath(targetPath)).toBe("partial output before the container died"); }).pipe(Effect.provide(s.layer)); }, ); @@ -1083,7 +1109,7 @@ describe("legacy migration squash", () => { } expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); // The full dump itself made it onto disk before the tail write failed. - expect(readFileSync(targetPath, "utf8")).toBe("full;\n"); + expect(yield* readTextPath(targetPath)).toBe("full;\n"); }).pipe(Effect.provide(s.layer)); }, ); @@ -1106,7 +1132,7 @@ describe("legacy migration squash", () => { // "non-empty", and proves the workdir-relative path (never the absolute one). expect(stderr(s.out)).toContain("FileSystem.remove (supabase/migrations/0_init.sql)"); // The file that failed to be removed is still on disk. - expect(existsSync(earlierPath)).toBe(true); + expect(yield* existsPath(earlierPath)).toBe(true); }).pipe(Effect.provide(s.layer)); }); diff --git a/apps/cli/src/legacy/commands/migration/up/up.handler.ts b/apps/cli/src/legacy/commands/migration/up/up.handler.ts index dcea6a5cf5..b7b825ba94 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.handler.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.handler.ts @@ -48,23 +48,19 @@ const runUp = Effect.fnUntraced(function* ( const dnsResolver = yield* LegacyDnsResolverFlag; if (target.setFlags.length > 1) { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${target.setFlags.join(" ")}] were all set`, + }); } // `--project-ref` never implies `--linked` and must not be silently // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && (target.connType ?? "local") !== "linked") { - return yield* Effect.fail( - new LegacyMigrationTargetFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyMigrationTargetFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const migrationsDir = path.join(cliConfig.workdir, "supabase", "migrations"); @@ -99,24 +95,20 @@ const runUp = Effect.fnUntraced(function* ( let pending: ReadonlyArray<string>; if (result.kind === "missing-local") { - return yield* Effect.fail( - new LegacyMigrationMissingLocalError({ - message: "Remote migration versions not found in local migrations directory.", - suggestion: legacySuggestRevertHistory( - result.versions, - (target.connType ?? "local") === "local", - ), - }), - ); + return yield* new LegacyMigrationMissingLocalError({ + message: "Remote migration versions not found in local migrations directory.", + suggestion: legacySuggestRevertHistory( + result.versions, + (target.connType ?? "local") === "local", + ), + }); } else if (result.kind === "missing-remote") { if (!flags.includeAll) { - return yield* Effect.fail( - new LegacyMigrationMissingRemoteError({ - message: - "Found local migration files to be inserted before the last migration on remote database.", - suggestion: suggestIgnoreFlag(result.paths), - }), - ); + return yield* new LegacyMigrationMissingRemoteError({ + message: + "Found local migration files to be inserted before the last migration on remote database.", + suggestion: suggestIgnoreFlag(result.paths), + }); } // `--include-all`: the out-of-order set + everything after the // applied prefix. Slices the same version-ordered list diff --git a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts index e903a225c2..8033da0337 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts @@ -1,8 +1,17 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { stripAnsi } from "../../../../../tests/helpers/ansi.ts"; import { @@ -31,10 +40,26 @@ import { import { LegacyMigrationVaultError } from "../../../shared/legacy-vault.ts"; import { legacyMigrationUp } from "./up.handler.ts"; import type { LegacyMigrationUpFlags } from "./up.command.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; const LIST_SQL = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; const READ_VAULT = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const pendingWrites: Array<{ readonly path: string; readonly contents: string }> = []; +const writeFileSync = (path: string, contents: string) => { + pendingWrites.push({ path, contents }); +}; +const flushFixtureWrites = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingWrites.length = 0; +}); + interface SetupOpts { readonly format?: OutputFormat; readonly args?: ReadonlyArray<string>; @@ -47,7 +72,6 @@ interface SetupOpts { function setup(workdir: string, opts: SetupOpts = {}) { if (opts.config !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync(join(workdir, "supabase", "config.toml"), opts.config); } const out = mockOutput({ format: opts.format ?? "text" }); @@ -121,6 +145,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const layer = Layer.mergeAll( + Layer.effectDiscard(flushFixtureWrites.pipe(Effect.provide(BunServices.layer))), out.layer, telemetry.layer, cache.layer, @@ -131,6 +156,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { Layer.succeed(LegacyDnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), BunServices.layer, + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), ); return { layer, out, telemetry, execs, queries, cache }; } @@ -145,7 +171,6 @@ const flags = (over: Partial<LegacyMigrationUpFlags> = {}): LegacyMigrationUpFla const seed = (workdir: string, name: string, body = "create table a;\n") => { const dir = join(workdir, "supabase", "migrations"); - mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, name), body); }; const insertedVersions = (queries: Array<{ sql: string; params?: ReadonlyArray<unknown> }>) => @@ -189,8 +214,10 @@ describe("legacy migration up", () => { expect(Option.isSome(failure) && failure.value._tag).toBe( "LegacyMigrationMissingLocalError", ); - expect(JSON.stringify(exit.cause)).toContain("migration repair --local --status reverted"); - expect(JSON.stringify(exit.cause)).toContain("supabase db pull --local"); + expect(Formatter.formatJson(exit.cause)).toContain( + "migration repair --local --status reverted", + ); + expect(Formatter.formatJson(exit.cause)).toContain("supabase db pull --local"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts index cd0dc55bf4..9a8016d691 100644 --- a/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-bans/get/get.integration.test.ts @@ -1,6 +1,6 @@ import { type V1ListAllNetworkBansOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -136,7 +136,7 @@ describe("legacy network-bans get integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(out.stderrText).toBe("DB banned IPs:\n"); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyNetworkBansEnvNotSupportedError"); expect(errJson).toContain("--output env flag is not supported"); } @@ -205,7 +205,7 @@ describe("legacy network-bans get integration", () => { const exit = yield* Effect.exit(legacyNetworkBansGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyNetworkBansGetUnexpectedStatusError"); expect(errJson).toContain("unexpected list bans status 503"); } @@ -218,7 +218,7 @@ describe("legacy network-bans get integration", () => { const exit = yield* Effect.exit(legacyNetworkBansGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyNetworkBansGetNetworkError"); expect(errJson).toContain("failed to list network bans:"); } diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts index c2c291d9af..04252bc3e1 100644 --- a/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.experimental-gate.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Layer, Formatter } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { normalizeCause } from "../../../shared/output/normalize-error.ts"; @@ -74,7 +74,7 @@ describe("legacy network-bans experimental gate (Go PersistentPreRunE parity)", ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyExperimentalRequiredError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -89,7 +89,7 @@ describe("legacy network-bans experimental gate (Go PersistentPreRunE parity)", ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = JSON.stringify(exit.cause); + const causeText = Formatter.formatJson(exit.cause); expect(causeText).not.toContain("LegacyExperimentalRequiredError"); expect(causeText).toContain("LegacyPlatformAuthRequiredError"); } @@ -119,7 +119,7 @@ describe("legacy network-bans experimental gate (Go PersistentPreRunE parity)", ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).not.toContain("LegacyExperimentalRequiredError"); + expect(Formatter.formatJson(exit.cause)).not.toContain("LegacyExperimentalRequiredError"); expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "\\"1.2.3.4" for "--db-unban-ip" flag: parse error on line 1, column 9: extraneous or missing " in quoted-field', ); diff --git a/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts index d091f30e69..cbf4814467 100644 --- a/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-bans/remove/remove.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -158,7 +158,7 @@ describe("legacy network-bans remove integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(0); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("invalid IP address: notanip"); + expect(Formatter.formatJson(exit.cause)).toContain("invalid IP address: notanip"); } }).pipe(Effect.provide(layer)); }); @@ -233,7 +233,7 @@ describe("legacy network-bans remove integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(0); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyNetworkBansInvalidIpError"); expect(errJson).toContain("invalid IP address: 12.3.4"); } @@ -264,7 +264,7 @@ describe("legacy network-bans remove integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(0); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyProjectNotLinkedError"); expect(errJson).not.toContain("LegacyNetworkBansInvalidIpError"); } @@ -286,7 +286,7 @@ describe("legacy network-bans remove integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(0); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyInvalidProjectRefError"); expect(errJson).not.toContain("LegacyNetworkBansInvalidIpError"); } @@ -305,7 +305,7 @@ describe("legacy network-bans remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyNetworkBansRemoveUnexpectedStatusError"); expect(errJson).toContain("unexpected unban status 503"); } @@ -323,7 +323,7 @@ describe("legacy network-bans remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = Formatter.formatJson(exit.cause); expect(errJson).toContain("LegacyNetworkBansRemoveNetworkError"); expect(errJson).toContain("failed to remove network bans:"); } diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/get.integration.test.ts b/apps/cli/src/legacy/commands/network-restrictions/get/get.integration.test.ts index e54d5a37b4..34387a057b 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/get/get.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetNetworkRestrictionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -248,7 +248,7 @@ describe("legacy network-restrictions get integration", () => { const exit = yield* Effect.exit(legacyNetworkRestrictionsGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsGetUnexpectedStatusError"); expect(errorJson).toContain("failed to retrieve network restrictions; received:"); } @@ -261,7 +261,7 @@ describe("legacy network-restrictions get integration", () => { const exit = yield* Effect.exit(legacyNetworkRestrictionsGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsGetNetworkError"); expect(errorJson).toContain("failed to retrieve network restrictions:"); } diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts index a0b1ada755..dafacb0f39 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Layer, Formatter } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { normalizeCause } from "../../../shared/output/normalize-error.ts"; @@ -96,7 +96,7 @@ describe("legacy network-restrictions experimental gate (Go PersistentPreRunE pa ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyExperimentalRequiredError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -111,7 +111,7 @@ describe("legacy network-restrictions experimental gate (Go PersistentPreRunE pa ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = JSON.stringify(exit.cause); + const causeText = Formatter.formatJson(exit.cause); expect(causeText).not.toContain("LegacyExperimentalRequiredError"); expect(causeText).toContain("LegacyPlatformAuthRequiredError"); } @@ -142,7 +142,7 @@ describe("legacy network-restrictions experimental gate (Go PersistentPreRunE pa ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).not.toContain("LegacyExperimentalRequiredError"); + expect(Formatter.formatJson(exit.cause)).not.toContain("LegacyExperimentalRequiredError"); expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "\\"1.2.3.0/24" for "--db-allow-cidr" flag: parse error on line 1, column 12: extraneous or missing " in quoted-field', ); diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/update.integration.test.ts b/apps/cli/src/legacy/commands/network-restrictions/update/update.integration.test.ts index c2bd0c72b8..fe7d3b94df 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/update/update.integration.test.ts @@ -4,7 +4,7 @@ import { type V1UpdateNetworkRestrictionsOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -249,7 +249,7 @@ describe("legacy network-restrictions update integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(0); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to parse IP: notacidr"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to parse IP: notacidr"); } }).pipe(Effect.provide(layer)); }); @@ -314,7 +314,7 @@ describe("legacy network-restrictions update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsInvalidCidrError"); expect(errorJson).toContain("failed to parse IP: 12.3.4.5"); } @@ -332,7 +332,7 @@ describe("legacy network-restrictions update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsPrivateIpError"); expect(errorJson).toContain("private IP provided: 10.0.0.0/8"); } @@ -354,7 +354,7 @@ describe("legacy network-restrictions update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsPrivateIpError"); expect(errorJson).toContain("private IP provided: ::ffff:10.0.0.0/104"); } @@ -403,7 +403,7 @@ describe("legacy network-restrictions update integration", () => { const exit = yield* Effect.exit(legacyNetworkRestrictionsUpdate(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsUpdateNetworkError"); expect(errorJson).toContain("failed to apply network restrictions:"); } @@ -416,7 +416,7 @@ describe("legacy network-restrictions update integration", () => { const exit = yield* Effect.exit(legacyNetworkRestrictionsUpdate(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsUpdateUnexpectedStatusError"); expect(errorJson).toContain("failed to apply network restrictions:"); } @@ -431,7 +431,7 @@ describe("legacy network-restrictions update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsUpdateNetworkError"); expect(errorJson).toContain("failed to apply network restrictions:"); } @@ -446,7 +446,7 @@ describe("legacy network-restrictions update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyNetworkRestrictionsUpdateUnexpectedStatusError"); expect(errorJson).toContain("failed to apply network restrictions:"); } @@ -630,7 +630,7 @@ describe("legacy network-restrictions update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts b/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts index 76f89fb92d..0b63a3b9e0 100644 --- a/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/orgs/create/create.integration.test.ts @@ -1,6 +1,6 @@ import type { V1CreateAnOrganizationOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Formatter, Option } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -166,7 +166,7 @@ describe("legacy orgs create integration", () => { const exit = yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsCreateUnexpectedStatusError"); expect(json).toContain("unexpected create organization status 503"); } @@ -179,7 +179,7 @@ describe("legacy orgs create integration", () => { const exit = yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsCreateNetworkError"); expect(json).toContain("failed to create organization"); } @@ -194,7 +194,7 @@ describe("legacy orgs create integration", () => { const exit = yield* Effect.exit(legacyOrgsCreate({ name: "Acme" })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsCreateNetworkError"); } }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts b/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts index 33c1399a1c..fe8895bae5 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.integration.test.ts @@ -1,6 +1,6 @@ import type { V1ListAllOrganizationsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Formatter, Option } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -150,7 +150,7 @@ describe("legacy orgs list integration", () => { const exit = yield* Effect.exit(legacyOrgsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsEnvNotSupportedError"); expect(json).toContain("--output env flag is not supported"); } @@ -194,7 +194,7 @@ describe("legacy orgs list integration", () => { const exit = yield* Effect.exit(legacyOrgsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsListUnexpectedStatusError"); expect(json).toContain("unexpected list organizations status 503"); } @@ -207,7 +207,7 @@ describe("legacy orgs list integration", () => { const exit = yield* Effect.exit(legacyOrgsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsListNetworkError"); expect(json).toContain("failed to list organizations"); } @@ -222,7 +222,7 @@ describe("legacy orgs list integration", () => { const exit = yield* Effect.exit(legacyOrgsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyOrgsListNetworkError"); } }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts b/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts index 2b4217489a..79d4434c64 100644 --- a/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/orgs/list/list.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { expect } from "vitest"; import { test } from "../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts index df19b2f9c6..ed0e82d623 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/postgres-config/delete/delete.command.unit.test.ts @@ -1,90 +1,83 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { normalizeCause } from "../../../../shared/output/normalize-error.ts"; import { legacyPostgresConfigDeleteConfigFlag } from "./delete.command.ts"; describe("legacy postgres-config delete --config flag (pflag StringSlice parity)", () => { - test("splits a comma-separated value into multiple keys", async () => { - const [, values] = await Effect.runPromise( - legacyPostgresConfigDeleteConfigFlag + it.live("splits a comma-separated value into multiple keys", () => + Effect.gen(function* () { + const [, values] = yield* legacyPostgresConfigDeleteConfigFlag .parse({ flags: { config: ["max_connections,statement_timeout"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); + .pipe(Effect.provide(BunServices.layer)); + expect(values).toEqual(["max_connections", "statement_timeout"]); + }), + ); - expect(values).toEqual(["max_connections", "statement_timeout"]); - }); - - test("accumulates repeated occurrences, each CSV-split", async () => { - const [, values] = await Effect.runPromise( - legacyPostgresConfigDeleteConfigFlag + it.live("accumulates repeated occurrences, each CSV-split", () => + Effect.gen(function* () { + const [, values] = yield* legacyPostgresConfigDeleteConfigFlag .parse({ flags: { config: ["max_connections,statement_timeout", "custom_key"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(values).toEqual(["max_connections", "statement_timeout", "custom_key"]); - }); + .pipe(Effect.provide(BunServices.layer)); + expect(values).toEqual(["max_connections", "statement_timeout", "custom_key"]); + }), + ); - test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Go-verified (CLI-2005): `postgres-config delete --config $'a\nb"c'` - // raises no parse error — pflag calls `csv.Reader.Read()` once, so the - // malformed second line is silently dropped. - const [, values] = await Effect.runPromise( - legacyPostgresConfigDeleteConfigFlag + it.live("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `postgres-config delete --config $'a\nb"c'` + // raises no parse error — pflag calls `csv.Reader.Read()` once, so the + // malformed second line is silently dropped. + const [, values] = yield* legacyPostgresConfigDeleteConfigFlag .parse({ flags: { config: ['a\nb"c'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(values).toEqual(["a"]); - }); + .pipe(Effect.provide(BunServices.layer)); + expect(values).toEqual(["a"]); + }), + ); - test("rejects malformed CSV (bare quote) with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacyPostgresConfigDeleteConfigFlag + it.live("rejects malformed CSV (bare quote) with pflag's exact diagnostic", () => + Effect.gen(function* () { + const exit = yield* legacyPostgresConfigDeleteConfigFlag .parse({ flags: { config: ['max"connections'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Matches pflag's own diagnostic (bare quote at byte 4 of `max"connections`). + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "max\\"connections" for "--config" flag: parse error on line 1, column 4: bare " in non-quoted-field', + ); + } + }), + ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Matches pflag's own diagnostic (bare quote at byte 4 of `max"connections`). - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "max\\"connections" for "--config" flag: parse error on line 1, column 4: bare " in non-quoted-field', - ); - } - }); - - test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `postgres-config delete --config $'\n'` → - // `invalid argument "\n" for "--config" flag: EOF`. - const exit = await Effect.runPromise( - legacyPostgresConfigDeleteConfigFlag + it.live("rejects a blank-only value with pflag's EOF diagnostic", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `postgres-config delete --config $'\n'` → + // `invalid argument "\n" for "--config" flag: EOF`. + const exit = yield* legacyPostgresConfigDeleteConfigFlag .parse({ flags: { config: ["\n"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\n" for "--config" flag: EOF', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\n" for "--config" flag: EOF', + ); + } + }), + ); }); diff --git a/apps/cli/src/legacy/commands/postgres-config/delete/delete.handler.ts b/apps/cli/src/legacy/commands/postgres-config/delete/delete.handler.ts index 2967166b74..8ac72e8720 100644 --- a/apps/cli/src/legacy/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/postgres-config/delete/delete.handler.ts @@ -44,14 +44,23 @@ export const legacyPostgresConfigDelete = Effect.fn("legacy.postgres-config.dele } const updated = yield* putPostgresConfig(ref, currentConfig, { - serializeError: (args) => new LegacyPostgresConfigDeleteSerializeError(args), - networkError: (args) => new LegacyPostgresConfigDeleteNetworkError(args), - statusError: (args) => new LegacyPostgresConfigDeleteUnexpectedStatusError(args), - unmarshalError: (args) => new LegacyPostgresConfigDeleteUnmarshalError(args), - networkMessage: (description) => `failed to delete config overrides: ${description}`, - statusMessage: (status, body) => + serializeError: (args: { readonly message: string }) => + new LegacyPostgresConfigDeleteSerializeError(args), + networkError: (args: { readonly message: string }) => + new LegacyPostgresConfigDeleteNetworkError(args), + statusError: (args: { + readonly status: number; + readonly body: string; + readonly message: string; + }) => new LegacyPostgresConfigDeleteUnexpectedStatusError(args), + unmarshalError: (args: { readonly message: string }) => + new LegacyPostgresConfigDeleteUnmarshalError(args), + networkMessage: (description: string) => + `failed to delete config overrides: ${description}`, + statusMessage: (status: number, body: string) => `unexpected delete config overrides status ${status}: ${body}`, - unmarshalMessage: (description) => `failed to unmarshal delete response: ${description}`, + unmarshalMessage: (description: string) => + `failed to unmarshal delete response: ${description}`, }).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); yield* deleting?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts index 2a965ce036..5a886d8281 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Layer, Schema } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { normalizeCause } from "../../../shared/output/normalize-error.ts"; @@ -15,6 +15,8 @@ import { } from "../../../../tests/helpers/legacy-mocks.ts"; import { legacyPostgresConfigCommand } from "./postgres-config.command.ts"; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + // This suite proves the `--experimental` gate is wired into the actual // `.command.ts` handler pipeline (not just the shared helper in isolation), // and — critically — that it runs BEFORE `legacyManagementApiRuntimeLayer` @@ -78,7 +80,7 @@ describe("legacy postgres-config experimental gate (Go PersistentPreRunE parity) ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + expect(encodeJson(exit.cause)).toContain("LegacyExperimentalRequiredError"); } // The gate must run before any API call (and before the eager // access-token resolution inside `legacyManagementApiRuntimeLayer`) — @@ -100,7 +102,7 @@ describe("legacy postgres-config experimental gate (Go PersistentPreRunE parity) // experimental gate error once the flag is on. expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = JSON.stringify(exit.cause); + const causeText = encodeJson(exit.cause); expect(causeText).not.toContain("LegacyExperimentalRequiredError"); expect(causeText).toContain("LegacyPlatformAuthRequiredError"); } @@ -145,7 +147,7 @@ describe("legacy postgres-config experimental gate (Go PersistentPreRunE parity) ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).not.toContain("LegacyExperimentalRequiredError"); + expect(encodeJson(exit.cause)).not.toContain("LegacyExperimentalRequiredError"); expect(normalizeCause(exit.cause).message).toBe(message); } expect(api.requests).toHaveLength(0); diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts index dba5877ec2..d8d66bab79 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -17,6 +17,8 @@ import { legacyPostgresConfigDelete } from "./delete/delete.handler.ts"; import { legacyPostgresConfigGet } from "./get/get.handler.ts"; import { legacyPostgresConfigUpdate } from "./update/update.handler.ts"; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + type LegacyOutput = "env" | "pretty" | "json" | "toml" | "yaml"; const tempRoot = useLegacyTempWorkdir("supabase-postgres-config-int-"); @@ -215,7 +217,7 @@ describe("legacy postgres-config get", () => { const exit = yield* Effect.exit(legacyPostgresConfigGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigGetUnexpectedStatusError"); expect(errorJson).toContain("unexpected config overrides status 503"); } @@ -231,7 +233,7 @@ describe("legacy postgres-config get", () => { const exit = yield* Effect.exit(legacyPostgresConfigGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigGetNetworkError"); expect(errorJson).toContain("failed to retrieve Postgres config overrides"); } @@ -380,7 +382,7 @@ describe("legacy postgres-config update", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigInvalidConfigValueError"); expect(errorJson).toContain("expected config value in key:value format"); } @@ -411,7 +413,7 @@ describe("legacy postgres-config update", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigUpdateUnexpectedStatusError"); expect(errorJson).toContain("unexpected update config overrides status 503"); } @@ -434,7 +436,7 @@ describe("legacy postgres-config update", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigGetNetworkError"); expect(errorJson).toContain("failed to retrieve Postgres config overrides"); } @@ -465,7 +467,7 @@ describe("legacy postgres-config update", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigUpdateNetworkError"); expect(errorJson).toContain("failed to update config overrides"); } @@ -594,7 +596,7 @@ describe("legacy postgres-config delete", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigDeleteUnexpectedStatusError"); } expect(telemetry.flushed).toBe(true); @@ -619,7 +621,7 @@ describe("legacy postgres-config delete", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigGetUnexpectedStatusError"); } expect(api.requests).toHaveLength(1); @@ -647,7 +649,7 @@ describe("legacy postgres-config delete", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = encodeJson(exit.cause); expect(errorJson).toContain("LegacyPostgresConfigDeleteNetworkError"); expect(errorJson).toContain("failed to delete config overrides"); } diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts index 72c8e18bb7..25615ac79e 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.shared.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Data, Effect, Option, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -17,6 +17,11 @@ import { legacyGoFormatFloat } from "../../shared/legacy-go-float.ts"; import { sanitizeLegacyErrorBody } from "../../shared/legacy-http-errors.ts"; import { requestWithAuth } from "../../shared/legacy-raw-http.ts"; import { resolveLegacyAccessToken } from "../../shared/legacy-resolve-token.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { LegacyPostgresConfigGetNetworkError, LegacyPostgresConfigGetUnexpectedStatusError, @@ -25,6 +30,16 @@ import { export type LegacyPostgresConfigMap = Record<string, unknown>; +const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +class PostgresConfigJsonShapeError extends Data.TaggedError("PostgresConfigJsonShapeError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} + function sortConfigEntries(config: LegacyPostgresConfigMap): Array<[string, unknown]> { return Object.entries(config).sort(([a], [b]) => a.localeCompare(b)); } @@ -133,15 +148,23 @@ function parseJsonObject<E>( wrap: (args: { readonly message: string }) => E, ): Effect.Effect<LegacyPostgresConfigMap, E> { return Effect.try({ - try: () => { - const parsed = JSON.parse(rawBody) as unknown; + try: () => decodeJsonString(rawBody), + catch: (cause) => wrap({ message: errorMessage(String(cause)) }), + }).pipe( + Effect.flatMap((parsed) => { if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("unexpected non-object JSON response"); + return Effect.fail( + new PostgresConfigJsonShapeError({ message: "unexpected non-object JSON response" }), + ); } - return parsed as LegacyPostgresConfigMap; - }, - catch: (cause) => wrap({ message: errorMessage(String(cause)) }), - }); + return Effect.succeed(Object.fromEntries(Object.entries(parsed))); + }), + Effect.mapError((cause) => + cause instanceof PostgresConfigJsonShapeError + ? wrap({ message: errorMessage(cause.message) }) + : cause, + ), + ); } export const fetchCurrentPostgresConfig = Effect.fn("legacy.postgres-config.fetch-current")( @@ -169,13 +192,11 @@ export const fetchCurrentPostgresConfig = Effect.fn("legacy.postgres-config.fetc if (response.status !== 200) { const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); const body = sanitizeLegacyErrorBody(rawBody); - return yield* Effect.fail( - new LegacyPostgresConfigGetUnexpectedStatusError({ - status: response.status, - body, - message: `unexpected config overrides status ${response.status}: ${body}`, - }), - ); + return yield* new LegacyPostgresConfigGetUnexpectedStatusError({ + status: response.status, + body, + message: `unexpected config overrides status ${response.status}: ${body}`, + }); } const rawBody = yield* response.text; @@ -208,58 +229,62 @@ export interface PutPostgresConfigErrors<SerErr, NetErr, StatErr, UnmErr> { readonly unmarshalMessage: (description: string) => string; } -export const putPostgresConfig = <SerErr, NetErr, StatErr, UnmErr>( +export const putPostgresConfig = Effect.fn("legacy.postgres-config.put")(function* < + SerErr, + NetErr, + StatErr, + UnmErr, +>( ref: string, config: LegacyPostgresConfigMap, errors: PutPostgresConfigErrors<SerErr, NetErr, StatErr, UnmErr>, -) => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const cliConfig = yield* LegacyCliConfig; - const tokenOpt = yield* resolveLegacyAccessToken; +) { + const httpClient = yield* HttpClient.HttpClient; + const cliConfig = yield* LegacyCliConfig; + const tokenOpt = yield* resolveLegacyAccessToken; - // Use raw HTTP instead of the generated input schema: Go accepts arbitrary - // config keys from repeated `--config key=value`, while the typed client - // only models the currently known OpenAPI fields. - const encodedBody = yield* Effect.try({ - try: () => encodeGoStructJsonBody(config), - catch: (cause) => - errors.serializeError({ - message: `failed to serialize config overrides: ${String(cause)}`, - }), - }); + // Use raw HTTP instead of the generated input schema: Go accepts arbitrary + // config keys from repeated `--config key=value`, while the typed client + // only models the currently known OpenAPI fields. + const encodedBody = yield* Effect.try({ + try: () => encodeGoStructJsonBody(config), + catch: (cause) => + errors.serializeError({ + message: `failed to serialize config overrides: ${String(cause)}`, + }), + }); - const request = requestWithAuth( - HttpClientRequest.put(`${cliConfig.apiUrl}/v1/projects/${ref}/config/database/postgres`).pipe( - HttpClientRequest.bodyText(encodedBody, "application/json"), + const request = requestWithAuth( + HttpClientRequest.put(`${cliConfig.apiUrl}/v1/projects/${ref}/config/database/postgres`).pipe( + HttpClientRequest.bodyText(encodedBody, "application/json"), + ), + tokenOpt, + cliConfig.userAgent, + ); + + const response = yield* httpClient + .execute(request) + .pipe( + Effect.mapError((cause) => + mapTransportMessage(cause, errors.networkMessage, errors.networkError), ), - tokenOpt, - cliConfig.userAgent, ); - const response = yield* httpClient - .execute(request) - .pipe( - Effect.mapError((cause) => - mapTransportMessage(cause, errors.networkMessage, errors.networkError), - ), - ); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeLegacyErrorBody(rawBody); - return yield* Effect.fail( - errors.statusError({ - status: response.status, - body, - message: errors.statusMessage(response.status, body), - }), - ); - } + if (response.status !== 200) { + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = sanitizeLegacyErrorBody(rawBody); + return yield* Effect.fail( + errors.statusError({ + status: response.status, + body, + message: errors.statusMessage(response.status, body), + }), + ); + } - const rawBody = yield* response.text; - return yield* parseJsonObject(rawBody, errors.unmarshalMessage, errors.unmarshalError); - }).pipe(Effect.withSpan("legacy.postgres-config.put")); + const rawBody = yield* response.text; + return yield* parseJsonObject(rawBody, errors.unmarshalMessage, errors.unmarshalError); +}); export const writePostgresConfigOutput = Effect.fn("legacy.postgres-config.write-output")( function* (config: LegacyPostgresConfigMap) { diff --git a/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts index ee8d9b556a..2c01a9caef 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/postgres-config/update/update.command.unit.test.ts @@ -1,91 +1,84 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { normalizeCause } from "../../../../shared/output/normalize-error.ts"; import { legacyPostgresConfigUpdateConfigFlag } from "./update.command.ts"; describe("legacy postgres-config update --config flag (pflag StringSlice parity)", () => { - test("splits a comma-separated value into multiple key=value pairs", async () => { - const [, values] = await Effect.runPromise( - legacyPostgresConfigUpdateConfigFlag + it.live("splits a comma-separated value into multiple key=value pairs", () => + Effect.gen(function* () { + const [, values] = yield* legacyPostgresConfigUpdateConfigFlag .parse({ flags: { config: ["max_connections=100,statement_timeout=600"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); + .pipe(Effect.provide(BunServices.layer)); + expect(values).toEqual(["max_connections=100", "statement_timeout=600"]); + }), + ); - expect(values).toEqual(["max_connections=100", "statement_timeout=600"]); - }); - - test("accumulates repeated occurrences, each CSV-split", async () => { - const [, values] = await Effect.runPromise( - legacyPostgresConfigUpdateConfigFlag + it.live("accumulates repeated occurrences, each CSV-split", () => + Effect.gen(function* () { + const [, values] = yield* legacyPostgresConfigUpdateConfigFlag .parse({ flags: { config: ["max_connections=100,statement_timeout=600", "custom_key=alpha"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(values).toEqual(["max_connections=100", "statement_timeout=600", "custom_key=alpha"]); - }); + .pipe(Effect.provide(BunServices.layer)); + expect(values).toEqual(["max_connections=100", "statement_timeout=600", "custom_key=alpha"]); + }), + ); - test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Go-verified (CLI-2005): `postgres-config update --config $'a=1\nb"2'` - // raises no parse error — pflag calls `csv.Reader.Read()` once, so the - // malformed second line is silently dropped. - const [, values] = await Effect.runPromise( - legacyPostgresConfigUpdateConfigFlag + it.live("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `postgres-config update --config $'a=1\nb"2'` + // raises no parse error — pflag calls `csv.Reader.Read()` once, so the + // malformed second line is silently dropped. + const [, values] = yield* legacyPostgresConfigUpdateConfigFlag .parse({ flags: { config: ['a=1\nb"2'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(values).toEqual(["a=1"]); - }); + .pipe(Effect.provide(BunServices.layer)); + expect(values).toEqual(["a=1"]); + }), + ); - test("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacyPostgresConfigUpdateConfigFlag + it.live("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", () => + Effect.gen(function* () { + const exit = yield* legacyPostgresConfigUpdateConfigFlag .parse({ flags: { config: ['"max_connections=100'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Matches pflag's own diagnostic (`"max_connections=100` is 20 bytes → + // EOF at column 21). + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\"max_connections=100" for "--config" flag: parse error on line 1, column 21: extraneous or missing " in quoted-field', + ); + } + }), + ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Matches pflag's own diagnostic (`"max_connections=100` is 20 bytes → - // EOF at column 21). - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\"max_connections=100" for "--config" flag: parse error on line 1, column 21: extraneous or missing " in quoted-field', - ); - } - }); - - test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `postgres-config update --config $'\n'` → - // `invalid argument "\n" for "--config" flag: EOF`. - const exit = await Effect.runPromise( - legacyPostgresConfigUpdateConfigFlag + it.live("rejects a blank-only value with pflag's EOF diagnostic", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `postgres-config update --config $'\n'` → + // `invalid argument "\n" for "--config" flag: EOF`. + const exit = yield* legacyPostgresConfigUpdateConfigFlag .parse({ flags: { config: ["\n"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\n" for "--config" flag: EOF', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\n" for "--config" flag: EOF', + ); + } + }), + ); }); diff --git a/apps/cli/src/legacy/commands/postgres-config/update/update.handler.ts b/apps/cli/src/legacy/commands/postgres-config/update/update.handler.ts index 83433ae2ef..e5efa9e288 100644 --- a/apps/cli/src/legacy/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/postgres-config/update/update.handler.ts @@ -61,14 +61,23 @@ export const legacyPostgresConfigUpdate = Effect.fn("legacy.postgres-config.upda normalizeTimeoutConfig(finalOverrides); const updated = yield* putPostgresConfig(ref, finalOverrides, { - serializeError: (args) => new LegacyPostgresConfigUpdateSerializeError(args), - networkError: (args) => new LegacyPostgresConfigUpdateNetworkError(args), - statusError: (args) => new LegacyPostgresConfigUpdateUnexpectedStatusError(args), - unmarshalError: (args) => new LegacyPostgresConfigUpdateUnmarshalError(args), - networkMessage: (description) => `failed to update config overrides: ${description}`, - statusMessage: (status, body) => + serializeError: (args: { readonly message: string }) => + new LegacyPostgresConfigUpdateSerializeError(args), + networkError: (args: { readonly message: string }) => + new LegacyPostgresConfigUpdateNetworkError(args), + statusError: (args: { + readonly status: number; + readonly body: string; + readonly message: string; + }) => new LegacyPostgresConfigUpdateUnexpectedStatusError(args), + unmarshalError: (args: { readonly message: string }) => + new LegacyPostgresConfigUpdateUnmarshalError(args), + networkMessage: (description: string) => + `failed to update config overrides: ${description}`, + statusMessage: (status: number, body: string) => `unexpected update config overrides status ${status}: ${body}`, - unmarshalMessage: (description) => `failed to unmarshal update response: ${description}`, + unmarshalMessage: (description: string) => + `failed to unmarshal update response: ${description}`, }).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); yield* updating?.clear() ?? Effect.void; diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts index edf3d9cbde..49d4a845c3 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts @@ -1,6 +1,8 @@ import type { V1GetProjectApiKeysOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -142,7 +144,7 @@ describe("legacy projects api-keys integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } }).pipe(Effect.provide(layer)); }); @@ -206,7 +208,7 @@ describe("legacy projects api-keys integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsApiKeysNetworkError"); expect(json).toContain("failed to get api keys"); } @@ -221,7 +223,7 @@ describe("legacy projects api-keys integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsApiKeysUnexpectedStatusError"); expect(json).toContain("unexpected get api keys status 503"); } diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts index b7b7ba7097..f9e2cd687c 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { expect } from "vitest"; import { test } from "../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts index 3e7bee3ff1..ef01503be4 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -1,9 +1,11 @@ import type { OrganizationResponseV1, V1CreateAProjectOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; + +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { Command } from "effect/unstable/cli"; -import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTelemetryRuntime, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyExperimentalFlag, @@ -62,6 +64,7 @@ interface SetupOpts { readonly promptPasswordResponses?: ReadonlyArray<string>; readonly tracked?: boolean; readonly experimental?: boolean; + readonly env?: Record<string, string>; } function setup(opts: SetupOpts = {}) { @@ -92,6 +95,7 @@ function setup(opts: SetupOpts = {}) { tty, telemetry: telemetry.layer, linkedProjectCache: cache.layer, + env: opts.env, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }); const layer = Layer.mergeAll( @@ -276,9 +280,7 @@ describe("legacy projects create integration", () => { it.live( "accepts --release-channel and --postgres-engine when only SUPABASE_EXPERIMENTAL is set", () => { - const { layer, api } = setup(); - const previous = process.env["SUPABASE_EXPERIMENTAL"]; - process.env["SUPABASE_EXPERIMENTAL"] = "true"; + const { layer, api } = setup({ env: { SUPABASE_EXPERIMENTAL: "true" } }); return Effect.gen(function* () { yield* legacyProjectsCreate({ ...BASE_FLAGS, @@ -291,18 +293,7 @@ describe("legacy projects create integration", () => { }); expect(postBody(api)?.release_channel).toBe("internal"); expect(postBody(api)?.postgres_engine).toBe("17-oriole"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EXPERIMENTAL"]; - } else { - process.env["SUPABASE_EXPERIMENTAL"] = previous; - } - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); @@ -344,7 +335,7 @@ describe("legacy projects create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsCreateMissingArgError"); expect(json).toContain("--org-id"); } @@ -365,7 +356,7 @@ describe("legacy projects create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsCreateMissingArgError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsCreateMissingArgError"); } // No prompts and no org fetch happened. expect(api.requests.some((r) => r.method === "GET")).toBe(false); @@ -400,7 +391,7 @@ describe("legacy projects create integration", () => { const exit = yield* Effect.exit(legacyProjectsCreate({ ...BASE_FLAGS })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsCreateNameEmptyError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsCreateNameEmptyError"); } }).pipe(Effect.provide(layer)); }); @@ -416,7 +407,7 @@ describe("legacy projects create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsOrgsListUnexpectedStatusError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsOrgsListUnexpectedStatusError"); } }).pipe(Effect.provide(layer)); }); @@ -509,7 +500,7 @@ describe("legacy projects create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsCreateNetworkError"); expect(json).toContain("failed to create project"); } @@ -530,7 +521,7 @@ describe("legacy projects create integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsCreateUnexpectedStatusError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsCreateUnexpectedStatusError"); } }).pipe(Effect.provide(layer)); }); @@ -550,7 +541,7 @@ describe("legacy projects create integration", () => { // server byte-compares the request body. JSON.parse → stringify // round-trips key order, so this asserts the on-the-wire order. const body = api.requests.find((r) => r.method === "POST")?.body; - expect(JSON.stringify(body)).toBe( + expect(stringifyJson(body)).toBe( '{"db_pass":"s3cret-pass","desired_instance_size":"micro","name":"alpha","organization_slug":"acme","region":"us-east-1"}', ); }).pipe(Effect.provide(layer)); @@ -615,6 +606,7 @@ describe("legacy projects create integration", () => { // previously listed "nano" as a valid choice, silently succeeding where // it should error. it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { + const { layer } = setup(); const root = Command.make("supabase").pipe( Command.withSubcommands([legacyProjectsCreateCommand]), Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), @@ -639,7 +631,7 @@ describe("legacy projects create integration", () => { if (Exit.isFailure(exit)) { expect(rejectsInvalidSizeChoice(Cause.squash(exit.cause))).toBe(true); } - }) as Effect.Effect<void>; + }).pipe(Effect.provide(Layer.mergeAll(layer, mockTelemetryRuntime()))); }); }); diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts index 4efdd9c15d..dc22f9451c 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.handler.ts @@ -127,10 +127,10 @@ export const legacyProjectsDelete = Effect.fn("legacy.projects.delete")(function // The link file written by `supabase link` holds exactly the ref. // Compare against the trimmed content so a corrupt/multi-ref file can't // trigger an unintended `.temp` removal. - const matches = yield* fs - .readFileString(refPath) - .pipe(Effect.map((content) => content.trim() === ref)) - .pipe(Effect.orElseSucceed(() => false)); + const matches = yield* fs.readFileString(refPath).pipe( + Effect.map((content) => content.trim() === ref), + Effect.orElseSucceed(() => false), + ); if (matches) { yield* fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore); } diff --git a/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts index c4868e0d82..bcfcd6613f 100644 --- a/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/delete/delete.integration.test.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { @@ -42,6 +39,9 @@ const SAMPLE_PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { }; const tempRoot = useLegacyTempWorkdir("supabase-projects-delete-int-"); +const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)); +const join = pathService.join; +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; @@ -53,6 +53,7 @@ interface SetupOpts { readonly network?: "fail"; readonly promptConfirmResponses?: ReadonlyArray<boolean>; readonly promptSelectResponses?: ReadonlyArray<string>; + readonly env?: Record<string, string>; } function setup(opts: SetupOpts = {}) { @@ -81,6 +82,7 @@ function setup(opts: SetupOpts = {}) { stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), telemetry: telemetry.layer, linkedProjectCache: cache.layer, + env: opts.env, }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), ); @@ -89,8 +91,18 @@ function setup(opts: SetupOpts = {}) { function writeRefFile(content: string) { const tempDir = join(tempRoot.current, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), content); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(join(tempDir, "project-ref"), content); + }); +} + +function pathExists(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }); } function hasMethod( @@ -125,7 +137,7 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); } expect(hasMethod(api, "DELETE")).toBe(false); }).pipe(Effect.provide(layer)); @@ -154,7 +166,7 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteRefRequiredError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsDeleteRefRequiredError"); } // No ref resolved → no linked-project cache write. expect(cache.cached).toBe(false); @@ -167,7 +179,7 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsDeleteCancelledError"); } // Established non-TTY behavior: still prints the label and echoes the // (empty) scanned line before the No default cancels. @@ -178,24 +190,14 @@ describe("legacy projects delete integration", () => { }); it.live("SUPABASE_YES=1 in the environment auto-confirms with the [y/N] y echo", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const { layer, out, api } = setup({ yes: false }); + const { layer, out, api } = setup({ yes: false, env: { SUPABASE_YES: "1" } }); return Effect.gen(function* () { yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); // Established `--yes` branch bytes. expect(out.stderrText).toContain("Do you want to delete project "); expect(out.stderrText).toContain("? This action is irreversible. [y/N] y\n"); expect(hasMethod(api, "DELETE")).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); it.live("non-TTY with piped `y` confirms like Go", () => { @@ -233,26 +235,28 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some("BADREF") })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(stringifyJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); it.live("removes the linked supabase/.temp dir when the deleted ref matches", () => { - writeRefFile(LEGACY_VALID_REF); const { layer } = setup({ yes: true }); return Effect.gen(function* () { + yield* writeRefFile(LEGACY_VALID_REF); yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); - expect(existsSync(join(tempRoot.current, "supabase", ".temp"))).toBe(false); + expect(yield* pathExists(join(tempRoot.current, "supabase", ".temp"))).toBe(false); }).pipe(Effect.provide(layer)); }); it.live("leaves the linked dir intact when the deleted ref differs", () => { - writeRefFile(OTHER_REF); const { layer } = setup({ yes: true }); return Effect.gen(function* () { + yield* writeRefFile(OTHER_REF); yield* legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) }); - expect(existsSync(join(tempRoot.current, "supabase", ".temp", "project-ref"))).toBe(true); + expect(yield* pathExists(join(tempRoot.current, "supabase", ".temp", "project-ref"))).toBe( + true, + ); }).pipe(Effect.provide(layer)); }); @@ -262,7 +266,7 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsDeleteNotFoundError"); expect(json).toContain(`Project does not exist:${LEGACY_VALID_REF}`); } @@ -275,7 +279,7 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsDeleteUnexpectedStatusError"); expect(json).toContain(`Failed to delete project ${LEGACY_VALID_REF}`); } @@ -288,7 +292,7 @@ describe("legacy projects delete integration", () => { const exit = yield* Effect.exit(legacyProjectsDelete({ ref: Option.some(LEGACY_VALID_REF) })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsDeleteNetworkError"); expect(json).toContain("failed to delete project"); } diff --git a/apps/cli/src/legacy/commands/projects/list/list.handler.ts b/apps/cli/src/legacy/commands/projects/list/list.handler.ts index 4325e552a7..619141d89a 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.handler.ts @@ -20,6 +20,7 @@ import { legacyGoTomlListWrapper, } from "../../../shared/legacy-go-struct-output.encoders.ts"; import { sanitizeLegacyErrorBody } from "../../../shared/legacy-http-errors.ts"; +import { legacyErrorMessage } from "../../../shared/legacy-error-message.ts"; import { LegacyProjectsEnvNotSupportedError, LegacyProjectsListNetworkError, @@ -91,7 +92,9 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.mapError( (cause) => - new LegacyProjectsListNetworkError({ message: `failed to list projects: ${cause}` }), + new LegacyProjectsListNetworkError({ + message: `failed to list projects: ${legacyErrorMessage(cause)}`, + }), ), ); @@ -114,7 +117,7 @@ export const legacyProjectsList = Effect.fn("legacy.projects.list")(function* ( new LegacyProjectsListUnexpectedStatusError({ status: response.status, body: "", - message: `Unexpected error retrieving projects: ${cause}`, + message: `Unexpected error retrieving projects: ${legacyErrorMessage(cause)}`, decode: true, }), ), diff --git a/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts b/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts index 65540f55c6..9de60482c9 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.integration.test.ts @@ -1,9 +1,6 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -55,6 +52,9 @@ const PARENT_PROJECT: Projects[number] = { }; const tempRoot = useLegacyTempWorkdir("supabase-projects-list-int-"); +const pathService = Effect.runSync(Effect.provide(Path.Path, Path.layer)); +const join = pathService.join; +const stringifyJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); // Distinct 20-lowercase-letter refs for the parent-fallback marker tests // below (CLI-2167 follow-up). @@ -65,20 +65,23 @@ function tempFile(workdir: string, name: string): string { return join(workdir, "supabase", ".temp", name); } -function writeTempContent(workdir: string, name: string, content: string): void { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(tempFile(workdir, name), content); +function writeTempContent(workdir: string, name: string, content: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString(tempFile(workdir, name), content); + }); } -function writeProjectRefFile(workdir: string, ref: string): void { - writeTempContent(workdir, "project-ref", ref); +function writeProjectRefFile(workdir: string, ref: string) { + return writeTempContent(workdir, "project-ref", ref); } -function writeLinkedProjectCacheFile(workdir: string, ref: string): void { - writeTempContent( +function writeLinkedProjectCacheFile(workdir: string, ref: string) { + return writeTempContent( workdir, "linked-project.json", - JSON.stringify({ + stringifyJson({ ref, name: "Parent Project", organization_id: "org_1", @@ -200,9 +203,9 @@ describe("legacy projects list integration", () => { projectId: Option.none(), response: [SAMPLE_PROJECT, PARENT_PROJECT], }); - writeProjectRefFile(workdir, BRANCH_OWN_REF); - writeLinkedProjectCacheFile(workdir, PARENT_PROJECT.id); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, PARENT_PROJECT.id); yield* legacyProjectsList({}); expect(out.stdoutText).toContain("●"); expect(out.stdoutText).toContain("parent"); @@ -216,9 +219,9 @@ describe("legacy projects list integration", () => { projectId: Option.none(), response: [SAMPLE_PROJECT, PARENT_PROJECT], }); - writeProjectRefFile(workdir, BRANCH_OWN_REF); - writeLinkedProjectCacheFile(workdir, PARENT_PROJECT.id); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, BRANCH_OWN_REF); + yield* writeLinkedProjectCacheFile(workdir, PARENT_PROJECT.id); yield* legacyProjectsList({}); const success = out.messages.find((m) => m.type === "success"); const projects = success?.data?.projects as ReadonlyArray<{ @@ -239,9 +242,9 @@ describe("legacy projects list integration", () => { }); // Directly linked to SAMPLE_PROJECT (a real row) — the cache pointing // elsewhere must be irrelevant since the exact match short-circuits. - writeProjectRefFile(workdir, SAMPLE_PROJECT.id); - writeLinkedProjectCacheFile(workdir, OTHER_CACHE_REF); return Effect.gen(function* () { + yield* writeProjectRefFile(workdir, SAMPLE_PROJECT.id); + yield* writeLinkedProjectCacheFile(workdir, OTHER_CACHE_REF); yield* legacyProjectsList({}); expect(out.stdoutText).toContain("●"); }).pipe(Effect.provide(layer)); @@ -323,7 +326,7 @@ describe("legacy projects list integration", () => { const exit = yield* Effect.exit(legacyProjectsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsEnvNotSupportedError"); expect(json).toContain("--output env flag is not supported"); } @@ -336,7 +339,7 @@ describe("legacy projects list integration", () => { const exit = yield* Effect.exit(legacyProjectsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = stringifyJson(exit.cause); expect(json).toContain("LegacyProjectsListNetworkError"); expect(json).toContain("failed to list projects"); } @@ -349,7 +352,7 @@ describe("legacy projects list integration", () => { const exit = yield* Effect.exit(legacyProjectsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsListUnexpectedStatusError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsListUnexpectedStatusError"); } }).pipe(Effect.provide(layer)); }); @@ -360,7 +363,7 @@ describe("legacy projects list integration", () => { const exit = yield* Effect.exit(legacyProjectsList({})); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectsListUnexpectedStatusError"); + expect(stringifyJson(exit.cause)).toContain("LegacyProjectsListUnexpectedStatusError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/projects/list/list.live.test.ts b/apps/cli/src/legacy/commands/projects/list/list.live.test.ts index 12db8ce9b7..8392d1499c 100644 --- a/apps/cli/src/legacy/commands/projects/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/projects/list/list.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { expect } from "vitest"; import { test } from "../../../../../tests/helpers/live.ts"; diff --git a/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts index a0ba966011..3aa8c30a1d 100644 --- a/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/list/list.integration.test.ts @@ -1,6 +1,6 @@ import { type V1ListAllSecretsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -14,6 +14,7 @@ import { import { legacySecretsList } from "./list.handler.ts"; type SecretsResponse = typeof V1ListAllSecretsOutput.Type; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const SAMPLE_SECRETS: SecretsResponse = [ { name: "FOO", value: "digest-foo" }, @@ -167,7 +168,7 @@ describe("legacy secrets list integration", () => { const exit = yield* Effect.exit(legacySecretsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsEnvNotSupportedError"); expect(errJson).toContain("--output env flag is not supported"); } @@ -219,7 +220,7 @@ describe("legacy secrets list integration", () => { const exit = yield* Effect.exit(legacySecretsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsListUnexpectedStatusError"); expect(errJson).toContain("unexpected list secrets status 503"); } @@ -232,7 +233,7 @@ describe("legacy secrets list integration", () => { const exit = yield* Effect.exit(legacySecretsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsListNetworkError"); expect(errJson).toContain("failed to list secrets"); } diff --git a/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts b/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts index 1b5e062d9e..b731c296e4 100644 --- a/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts +++ b/apps/cli/src/legacy/commands/secrets/list/list.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { randomUUID } from "node:crypto"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index ea65bfeba9..5a72cff78d 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -8,7 +8,7 @@ import { } from "@supabase/config"; import { V1BulkCreateSecretsInput } from "@supabase/api/effect"; import { parse as parseDotenv } from "dotenv"; -import { Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; +import { ConfigProvider, Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -16,6 +16,7 @@ import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.t import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { collectConfigEnvironment } from "../../../../shared/runtime/config-environment.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { @@ -153,6 +154,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( const runtimeInfo = yield* RuntimeInfo; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const provider = yield* ConfigProvider.ConfigProvider; const ref = yield* resolver.resolve(flags.projectRef); @@ -259,33 +261,33 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // in this failure path, unlike the schema-decode-only case above. Recover // to `null`, not `recoverEdgeRuntimeConfig`: there is no parsed document // to recover a subtree from. - Effect.catchTag("ProjectEnvParseError", (cause) => - debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)), - ), - // Two `[remotes.*]` blocks declare the same `project_id` as `ref` — - // `flags.LoadConfig` swallows *any* `Load()` error non-fatally, - // including this one, which `loadFromFile` raises before - // `mapstructure` ever runs (`pkg/config/config.go:601`). - // `cause.message` already matches that string verbatim (see - // `DuplicateRemoteProjectIdError`'s field doc). - Effect.catchTag("DuplicateRemoteProjectIdError", (cause) => - debugLogger.debug(cause.message).pipe(Effect.as(null)), - ), - // A `[remotes.*]` block's `project_id` fails the ref-pattern check — - // raised from `Config.Validate` (`pkg/config/config.go:996-1001`), which - // runs inside the same `Config.Load()` call as the duplicate check above - // (`config.go:882`). `flags.LoadConfig` swallows this the same - // non-fatal way, so a malformed remote block must not abort an - // otherwise-valid `secrets set`. `cause.message` already matches that - // string verbatim (see `InvalidRemoteProjectIdError`'s field doc). - Effect.catchTag("InvalidRemoteProjectIdError", (cause) => - debugLogger.debug(cause.message).pipe(Effect.as(null)), - ), + Effect.catchTags({ + ProjectEnvParseError: (cause) => + debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)), + // Two `[remotes.*]` blocks declare the same `project_id` as `ref` — + // `flags.LoadConfig` swallows *any* `Load()` error non-fatally, + // including this one, which `loadFromFile` raises before + // `mapstructure` ever runs (`pkg/config/config.go:601`). + // `cause.message` already matches that string verbatim (see + // `DuplicateRemoteProjectIdError`'s field doc). + DuplicateRemoteProjectIdError: (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), + // A `[remotes.*]` block's `project_id` fails the ref-pattern check — + // raised from `Config.Validate` (`pkg/config/config.go:996-1001`), which + // runs inside the same `Config.Load()` call as the duplicate check above + // (`config.go:882`). `flags.LoadConfig` swallows this the same + // non-fatal way, so a malformed remote block must not abort an + // otherwise-valid `secrets set`. `cause.message` already matches that + // string verbatim (see `InvalidRemoteProjectIdError`'s field doc). + InvalidRemoteProjectIdError: (cause) => + debugLogger.debug(cause.message).pipe(Effect.as(null)), + }), ); if (loadedConfig !== null) { + const baseEnv = yield* collectConfigEnvironment(provider); const projectEnv = yield* loadProjectEnvironment({ cwd: runtimeInfo.cwd, - baseEnv: process.env, + baseEnv, }); if (projectEnv !== null) { const resolved = yield* resolveProjectSubtree( @@ -330,16 +332,13 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( }), ), ); - let parsed: Record<string, string>; - try { - parsed = parseDotenv(content); - } catch (cause) { - return yield* Effect.fail( + const parsed = yield* Effect.try({ + try: () => parseDotenv(content), + catch: (cause) => new LegacySecretsEnvFileParseError({ message: `failed to parse env file: ${String(cause)}`, }), - ); - } + }); for (const [name, value] of Object.entries(parsed)) { merged.set(name, value); } @@ -349,12 +348,10 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( for (const pair of flags.secrets) { const eqIdx = pair.indexOf("="); if (eqIdx === -1) { - return yield* Effect.fail( - new LegacyInvalidSecretPairError({ - pair, - message: `Invalid secret pair: ${pair}. Must be NAME=VALUE.`, - }), - ); + return yield* new LegacyInvalidSecretPairError({ + pair, + message: `Invalid secret pair: ${pair}. Must be NAME=VALUE.`, + }); } merged.set(pair.slice(0, eqIdx), pair.slice(eqIdx + 1)); } @@ -373,11 +370,9 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( } if (body.length === 0) { - return yield* Effect.fail( - new LegacySecretsNoArgumentsError({ - message: "No arguments found. Use --env-file to read from a .env file.", - }), - ); + return yield* new LegacySecretsNoArgumentsError({ + message: "No arguments found. Use --env-file to read from a .env file.", + }); } // The Management API caps a single bulk-create request at 100 secrets @@ -399,7 +394,7 @@ export const legacySecretsSet = Effect.fn("legacy.secrets.set")(function* ( // user-derived, so keep it distinct from response-schema decode failures. yield* Effect.forEach( batches, - (batch) => Schema.decodeUnknownEffect(V1BulkCreateSecretsInput)({ ref, body: batch }), + (batch) => Schema.decodeEffect(V1BulkCreateSecretsInput)({ ref, body: batch }), { discard: true }, ).pipe( Effect.mapError( diff --git a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts index 2286c084a2..660494eed6 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.integration.test.ts @@ -1,9 +1,16 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, PlatformError } from "effect"; +import { + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + PlatformError, + Schema, +} from "effect"; import { mockOutput, @@ -21,6 +28,10 @@ import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.t import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { legacySecretsSet } from "./set.handler.ts"; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); + function mockLegacyDebugLoggerTracked() { const messages: Array<string> = []; return { @@ -91,13 +102,23 @@ function setup(opts: SetupOpts = {}) { } function writeConfig(content: string) { - mkdirSync(join(tempRoot.current, "supabase"), { recursive: true }); - writeFileSync(join(tempRoot.current, "supabase", "config.toml"), content); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, "supabase"), { recursive: true }); + yield* fs.writeFileString(join(tempRoot.current, "supabase", "config.toml"), content); + }); } function writeSupabaseDotEnv(content: string) { - mkdirSync(join(tempRoot.current, "supabase"), { recursive: true }); - writeFileSync(join(tempRoot.current, "supabase", ".env"), content); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(join(tempRoot.current, "supabase"), { recursive: true }); + yield* fs.writeFileString(join(tempRoot.current, "supabase", ".env"), content); + }); +} + +function writeFixture(path: string, content: string) { + return Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(path, content)); } function parsePostBody(body: unknown): Array<{ name: string; value: string }> { @@ -195,7 +216,7 @@ describe("legacy secrets set integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsSetInputError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsSetInputError"); const classified = classifyCliCauseActionability(exit.cause); expect(classified.error_kind).toBe("user_actionable"); expect(classified.error_category).toBe("invalid_input"); @@ -206,9 +227,9 @@ describe("legacy secrets set integration", () => { ); it.live("sets secrets from --env-file with a relative path (joined to CWD)", () => { - writeFileSync(join(tempRoot.current, "myfile.env"), "FROM_FILE=fromvalue\n"); const { layer, api } = setup(); return Effect.gen(function* () { + yield* writeFixture(join(tempRoot.current, "myfile.env"), "FROM_FILE=fromvalue\n"); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.some("myfile.env"), @@ -222,9 +243,9 @@ describe("legacy secrets set integration", () => { it.live("sets secrets from --env-file with an absolute path", () => { const abs = join(tempRoot.current, "absolute.env"); - writeFileSync(abs, "ABS=value\n"); const { layer, api } = setup(); return Effect.gen(function* () { + yield* writeFixture(abs, "ABS=value\n"); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.some(abs), @@ -235,9 +256,9 @@ describe("legacy secrets set integration", () => { }); it.live("CLI args override --env-file entries for the same key", () => { - writeFileSync(join(tempRoot.current, "override.env"), "FOO=from-file\n"); const { layer, api } = setup(); return Effect.gen(function* () { + yield* writeFixture(join(tempRoot.current, "override.env"), "FOO=from-file\n"); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.some("override.env"), @@ -250,15 +271,15 @@ describe("legacy secrets set integration", () => { it.live( "merges entries from supabase/config.toml [edge_runtime.secrets] ahead of env-file and CLI args", () => { - writeConfig( - `[edge_runtime.secrets] + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] FROM_CONFIG = "config-value" SHARED = "config-shared" `, - ); - writeFileSync(join(tempRoot.current, ".env-file"), "SHARED=envfile-shared\n"); - const { layer, api } = setup(); - return Effect.gen(function* () { + ); + yield* writeFixture(join(tempRoot.current, ".env-file"), "SHARED=envfile-shared\n"); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.some(".env-file"), @@ -276,13 +297,13 @@ SHARED = "config-shared" ); it.live("interpolates env(VAR) in config.toml secrets when the env var is defined", () => { - writeConfig( - `[edge_runtime.secrets] -DB_URL = "env(MY_DB_URL)" -`, - ); const { layer, api } = setup({ env: { MY_DB_URL: "postgres://x" } }); return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] +DB_URL = "env(MY_DB_URL)" +`, + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -295,15 +316,15 @@ DB_URL = "env(MY_DB_URL)" }); it.live("skips secrets whose env() reference cannot be resolved (Go set.go:48-52 parity)", () => { - writeConfig( - `[edge_runtime.secrets] + const { layer, api } = setup({ env: { MY_DB_URL: "postgres://x" } }); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] RESOLVED = "env(MY_DB_URL)" UNRESOLVED = "env(NOT_SET_ANYWHERE)" LITERAL = "plain-value" `, - ); - const { layer, api } = setup({ env: { MY_DB_URL: "postgres://x" } }); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -328,14 +349,14 @@ LITERAL = "plain-value" // are included — so a literal `EMPTY = ""` in config.toml is never // sent, which prevents it from silently overwriting a same-named // remote secret with an empty string. - writeConfig( - `[edge_runtime.secrets] + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] EMPTY = "" NON_EMPTY = "config-value" `, - ); - const { layer, api } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -351,16 +372,16 @@ NON_EMPTY = "config-value" it.live( "does not crash when config.toml has env(NUMERIC_PORT) on an unrelated numeric field (CLI-1489 regression guard)", () => { - writeConfig( - `[analytics] + const { layer, api } = setup({ env: { SUPABASE_ANALYTICS_PORT: "54327" } }); + return Effect.gen(function* () { + yield* writeConfig( + `[analytics] port = "env(SUPABASE_ANALYTICS_PORT)" [edge_runtime.secrets] FOO = "literal-foo" `, - ); - const { layer, api } = setup({ env: { SUPABASE_ANALYTICS_PORT: "54327" } }); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -403,7 +424,7 @@ FOO = "literal-foo" ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsNoArgumentsError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsNoArgumentsError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -422,7 +443,7 @@ FOO = "literal-foo" ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacyInvalidSecretPairError"); expect(errJson).toContain("Invalid secret pair: NOTAPAIR"); } @@ -442,7 +463,7 @@ FOO = "literal-foo" ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsEnvFileOpenError"); expect(errJson).toContain("failed to open env file"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ @@ -482,9 +503,9 @@ FOO = "literal-foo" it.live( "tolerates a malformed config.toml, logs it to the debug logger, and still sets CLI-arg secrets", () => { - writeConfig("this is not valid = = toml [[[\n"); const { layer, api, debugLogger } = setup(); return Effect.gen(function* () { + yield* writeConfig("this is not valid = = toml [[[\n"); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -508,16 +529,16 @@ FOO = "literal-foo" // landing on `utils.Config` — `secrets set` still reads it. Effect // Schema's `decodeUnknownSync` is atomic and would otherwise discard the // whole document, silently dropping `FROM_CONFIG` too. - writeConfig( - `[edge_runtime.secrets] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] FROM_CONFIG = "config-value" [analytics] port = "not-a-number" `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -544,16 +565,16 @@ port = "not-a-number" // `InspectorPort` is left at its zero value — verified empirically // against `pkg/config` directly. The recovery must therefore re-decode // `secrets` on its own rather than the whole `edge_runtime` subtree. - writeConfig( - `[edge_runtime] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime] inspector_port = "not-a-number" [edge_runtime.secrets] FROM_CONFIG = "config-value" `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -579,14 +600,14 @@ FROM_CONFIG = "config-value" // non-fatal way — so this must not abort the command either. `.env` // is only read once a `supabase/config.toml`/`.json` is found // (`findProjectPaths`), so a config.toml must exist here too. - writeConfig( - `[edge_runtime.secrets] -FROM_CONFIG = "config-value" -`, - ); - writeSupabaseDotEnv("THIS IS NOT A VALID DOTENV LINE\n"); const { layer, api, debugLogger } = setup(); return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); + yield* writeSupabaseDotEnv("THIS IS NOT A VALID DOTENV LINE\n"); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -611,14 +632,14 @@ FROM_CONFIG = "config-value" // `utils.Config.EdgeRuntime.Secrets` even with `BAD` present. Effect // Schema's `decodeUnknownSync` is atomic per record and would otherwise // discard `GOOD` too when re-decoding the whole `secrets` map at once. - writeConfig( - `[edge_runtime.secrets] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] GOOD = "config-value" BAD = 123 `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -640,17 +661,17 @@ BAD = 123 // fine on its own (it's a valid, if empty, string), so it must be // dropped downstream in the same merge loop the happy path uses, not // resurrected as a false "recoverable" entry. - writeConfig( - `[edge_runtime.secrets] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] EMPTY = "" GOOD = "config-value" [analytics] port = "not-a-number" `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -675,16 +696,16 @@ port = "not-a-number" // `decodeMapFromSlice` path, and the whole field is left empty. Before // the `isRecord` fix, `Object.entries(["actual-secret"])` would turn // this into a spurious `{ "0": "actual-secret" }` entry. - writeConfig( - `[analytics] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[analytics] port = "not-a-number" [edge_runtime] secrets = ["actual-secret"] `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -709,8 +730,10 @@ secrets = ["actual-secret"] // override in `loadFromFile` (`pkg/config/config.go:604-609`) before the // tolerant decode this PR models — the recovered secret must reflect the // remote's override value, not the base document's. - writeConfig( - `[edge_runtime.secrets] + const { layer, out, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] FROM_CONFIG = "base-value" [analytics] @@ -722,9 +745,7 @@ project_id = "${LEGACY_VALID_REF}" [remotes.staging.edge_runtime.secrets] FROM_CONFIG = "remote-value" `, - ); - const { layer, out, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -752,8 +773,10 @@ FROM_CONFIG = "remote-value" // unconditionally whenever a `[remotes.*]` block's `project_id` matches // `Config.ProjectId`, before `mapstructure` ever runs. `mockLegacyCliConfig` // defaults the resolved ref to `LEGACY_VALID_REF`. - writeConfig( - `[edge_runtime.secrets] + const { layer, out, api } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] FROM_CONFIG = "base-value" [remotes.staging] @@ -762,9 +785,7 @@ project_id = "${LEGACY_VALID_REF}" [remotes.staging.edge_runtime.secrets] FROM_CONFIG = "remote-value" `, - ); - const { layer, out, api } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -781,13 +802,13 @@ FROM_CONFIG = "remote-value" it.live( "does not print a remote override notice when no [remotes.*] block matches the resolved ref", () => { - writeConfig( - `[edge_runtime.secrets] -FROM_CONFIG = "config-value" -`, - ); const { layer, out, api } = setup(); return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] +FROM_CONFIG = "config-value" +`, + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -809,8 +830,10 @@ FROM_CONFIG = "config-value" // before `mapstructure` ever runs (`pkg/config/config.go:601`). There // is no parsed document to recover a subtree from, so config-sourced // secrets are dropped entirely — only CLI-arg secrets survive. - writeConfig( - `[edge_runtime.secrets] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] FROM_CONFIG = "config-value" [remotes.a] @@ -819,9 +842,7 @@ project_id = "dupe-project-id" [remotes.b] project_id = "dupe-project-id" `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -845,16 +866,16 @@ project_id = "dupe-project-id" // above. There is no parsed document to recover a subtree from, so // config-sourced secrets are dropped entirely — only CLI-arg secrets // survive. - writeConfig( - `[edge_runtime.secrets] + const { layer, api, debugLogger } = setup(); + return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] FROM_CONFIG = "config-value" [remotes.a] project_id = "not-a-valid-ref" `, - ); - const { layer, api, debugLogger } = setup(); - return Effect.gen(function* () { + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -873,15 +894,15 @@ project_id = "not-a-valid-ref" // `smol-toml`'s `TomlError` embeds a source codeblock (the offending line ±1) // in its message; the planted secret sits directly above the syntax error so // it would land inside that codeblock if the handler logged the raw message. - writeConfig( - [ - "[edge_runtime.secrets]", - 'PLANTED_SECRET = "sk_live_TOTALLY_REAL_SECRET_VALUE"', - "BROKEN = = invalid[[[", - ].join("\n"), - ); const { layer, debugLogger } = setup(); return Effect.gen(function* () { + yield* writeConfig( + [ + "[edge_runtime.secrets]", + 'PLANTED_SECRET = "sk_live_TOTALLY_REAL_SECRET_VALUE"', + "BROKEN = = invalid[[[", + ].join("\n"), + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -904,13 +925,13 @@ project_id = "not-a-valid-ref" // sits inside `[edge_runtime.secrets]` itself, so this also exercises // the per-entry recovery path — `PLANTED_SECRET` is dropped, but the // CLI-arg secret still goes through. - writeConfig( - `[edge_runtime.secrets] -PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] -`, - ); const { layer, api, debugLogger } = setup(); return Effect.gen(function* () { + yield* writeConfig( + `[edge_runtime.secrets] +PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] +`, + ); yield* legacySecretsSet({ projectRef: Option.none(), envFile: Option.none(), @@ -927,9 +948,9 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] it.live( "still fails with LegacySecretsNoArgumentsError when a malformed config leaves zero secret sources", () => { - writeConfig("this is not valid = = toml [[[\n"); const { layer, api } = setup(); return Effect.gen(function* () { + yield* writeConfig("this is not valid = = toml [[[\n"); const exit = yield* Effect.exit( legacySecretsSet({ projectRef: Option.none(), @@ -939,7 +960,7 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsNoArgumentsError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsNoArgumentsError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -958,7 +979,7 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsSetNetworkError"); expect(errJson).toContain("failed to set secrets"); } @@ -977,7 +998,7 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsSetUnexpectedStatusError"); expect(errJson).toContain("Unexpected error setting project secrets"); } diff --git a/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts b/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts index 617ad85c8c..daa173d181 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { randomUUID } from "node:crypto"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts index c4076338d1..e3f58c61eb 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.handler.ts @@ -77,9 +77,7 @@ export const legacySecretsUnset = Effect.fn("legacy.secrets.unset")(function* ( const confirmed = yield* legacyPromptYesNo(output, yes, label, true); if (!confirmed) { - return yield* Effect.fail( - new LegacySecretsUnsetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacySecretsUnsetCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } const unsetting = diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts index 3c19a2547f..aef65e2d84 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.integration.test.ts @@ -1,6 +1,6 @@ import { type V1ListAllSecretsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, Layer, Option, Schema } from "effect"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; @@ -30,9 +30,11 @@ interface SetupOpts { listNetwork?: "fail"; deleteStatus?: number; deleteNetwork?: "fail"; + env?: Record<string, string>; } const tempRoot = useLegacyTempWorkdir("supabase-secrets-unset-int-"); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); function setup(opts: SetupOpts = {}) { const out = mockOutput({ @@ -64,6 +66,7 @@ function setup(opts: SetupOpts = {}) { tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), stdin: mockStdin(opts.stdinIsTty ?? false, opts.stdinInput), goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + env: opts.env, }), Layer.succeed(LegacyYesFlag, opts.yes ?? false), ); @@ -170,7 +173,7 @@ describe("legacy secrets unset integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsUnsetCancelledError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsUnsetCancelledError"); } // The piped answer is echoed to stderr, matching non-TTY `PromptText`. expect(out.stderrText).toContain("[Y/n] n\n"); @@ -188,9 +191,7 @@ describe("legacy secrets unset integration", () => { }); it.live("SUPABASE_YES=1 in the environment auto-confirms with the [Y/n] y echo", () => { - const prev = process.env["SUPABASE_YES"]; - process.env["SUPABASE_YES"] = "1"; - const { layer, out, api } = setup(); + const { layer, out, api } = setup({ env: { SUPABASE_YES: "1" } }); return Effect.gen(function* () { yield* legacySecretsUnset({ projectRef: Option.none(), names: ["FOO"] }); // Same bytes as the `viper.GetBool("YES")` branch (`console.go:70-72`). @@ -198,15 +199,7 @@ describe("legacy secrets unset integration", () => { "Do you want to unset these function secrets?\n • FOO\n\n [Y/n] y\n", ); expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(1); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_YES"]; - else process.env["SUPABASE_YES"] = prev; - }), - ), - Effect.provide(layer), - ); + }).pipe(Effect.provide(layer)); }); it.live("TTY without --yes prompts via output.promptConfirm and proceeds on accept", () => { @@ -225,7 +218,7 @@ describe("legacy secrets unset integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsUnsetCancelledError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsUnsetCancelledError"); } expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -237,7 +230,7 @@ describe("legacy secrets unset integration", () => { const exit = yield* Effect.exit(legacySecretsUnset({ projectRef: Option.none(), names: [] })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsListNetworkError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsListNetworkError"); } }).pipe(Effect.provide(layer)); }); @@ -248,7 +241,7 @@ describe("legacy secrets unset integration", () => { const exit = yield* Effect.exit(legacySecretsUnset({ projectRef: Option.none(), names: [] })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySecretsListUnexpectedStatusError"); + expect(encodeJson(exit.cause)).toContain("LegacySecretsListUnexpectedStatusError"); } }).pipe(Effect.provide(layer)); }); @@ -261,7 +254,7 @@ describe("legacy secrets unset integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsUnsetNetworkError"); expect(errJson).toContain("failed to delete secrets"); } @@ -276,7 +269,7 @@ describe("legacy secrets unset integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errJson = JSON.stringify(exit.cause); + const errJson = encodeJson(exit.cause); expect(errJson).toContain("LegacySecretsUnsetUnexpectedStatusError"); expect(errJson).toContain("Unexpected error unsetting project secrets"); } diff --git a/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts b/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts index ede0a6dc6b..08314712af 100644 --- a/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts +++ b/apps/cli/src/legacy/commands/secrets/unset/unset.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this live test uses Vitest's Promise surface to drive the real CLI. import { randomUUID } from "node:crypto"; import { expect } from "vitest"; diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts index b21fa02eeb..ecf4c84ea3 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.e2e.test.ts @@ -1,7 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase } from "../../../../../tests/helpers/cli.ts"; @@ -17,39 +16,49 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase seed buckets (legacy)", () => { let projectDir: string; - beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-seed-buckets-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test"\n'); - }); + beforeAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + projectDir = yield* fs.makeTempDirectory({ prefix: "supabase-seed-buckets-e2e-" }); + const supabaseDir = path.join(projectDir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), 'project_id = "test"\n'); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); + afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - test( - "is a no-op with exit 0 when no buckets are configured", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["seed", "buckets"], { - entrypoint: "legacy", - cwd: projectDir, - }); + test("is a no-op with exit 0 when no buckets are configured", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["seed", "buckets"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ exitCode, stdout }) => { expect(exitCode).toBe(0); expect(stdout.trim()).toBe(""); - }, + }), ); - test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["seed", "buckets", "--local", "--linked"], - { entrypoint: "legacy", cwd: projectDir }, - ); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [local linked] are set none of the others can be", - ); - }); + test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["seed", "buckets", "--local", "--linked"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [local linked] are set none of the others can be", + ); + }), + ); // Go registers --linked/--local on seedCmd.PersistentFlags() (seed.go:27-29), // so they're accepted BEFORE the subcommand too. These two cases exercise the @@ -57,26 +66,27 @@ describe("supabase seed buckets (legacy)", () => { test( "accepts --local before the subcommand (Go PersistentFlags)", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["seed", "--local", "buckets"], { + () => + runSupabase(["seed", "--local", "buckets"], { entrypoint: "legacy", cwd: projectDir, - }); - // Parsed (no "Unrecognized flag") and routed to the local no-op path. - expect(`${stdout}${stderr}`).not.toContain("Unrecognized flag"); - expect(exitCode).toBe(0); - expect(stdout.trim()).toBe(""); - }, + }).then(({ exitCode, stdout, stderr }) => { + // Parsed (no "Unrecognized flag") and routed to the local no-op path. + expect(`${stdout}${stderr}`).not.toContain("Unrecognized flag"); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(""); + }), ); - test("rejects --local --linked before the subcommand", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["seed", "--local", "--linked", "buckets"], - { entrypoint: "legacy", cwd: projectDir }, - ); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [local linked] are set none of the others can be", - ); - }); + test("rejects --local --linked before the subcommand", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["seed", "--local", "--linked", "buckets"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [local linked] are set none of the others can be", + ); + }), + ); }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts index 889c356c8b..496b87e63a 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.flags.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, Formatter } from "effect"; import { legacyAssertSeedTargetsExclusive, legacySeedChangedTargetFlags } from "./buckets.flags.ts"; @@ -57,7 +57,7 @@ describe("legacyAssertSeedTargetsExclusive", () => { legacyAssertSeedTargetsExclusive(["seed", "buckets", "--local", "--linked"]), ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "if any flags in the group [local linked] are set none of the others can be; [linked local] were all set", ); }); @@ -67,7 +67,7 @@ describe("legacyAssertSeedTargetsExclusive", () => { legacyAssertSeedTargetsExclusive(["seed", "buckets", "--no-local", "--linked"]), ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("[linked local] were all set"); + expect(Formatter.formatJson(exit)).toContain("[linked local] were all set"); }); it("succeeds when at most one target flag is set", () => { diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts index 101fc0abb4..568c4c9a76 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.handler.ts @@ -48,12 +48,10 @@ export const legacySeedBuckets = Effect.fn("legacy.seed.buckets")(function* ( // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && !isLinked) { - return yield* Effect.fail( - new LegacySeedMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }), - ); + return yield* new LegacySeedMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }); } const projectRefResolver = yield* LegacyProjectRefResolver; diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts index da06669a3c..418184a3e1 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.integration.test.ts @@ -1,10 +1,19 @@ -import { execFileSync } from "node:child_process"; -import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, +} from "effect"; +import * as PlatformError from "effect/PlatformError"; +import * as Formatter from "effect/Formatter"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import type * as HttpClientError from "effect/unstable/http/HttpClientError"; @@ -22,10 +31,12 @@ import { } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import type { OutputFormat } from "../../../../shared/output/types.ts"; import { LegacyProjectRefResolver } from "../../../../legacy/config/legacy-project-ref.service.ts"; import { LegacyProjectNotLinkedError } from "../../../../legacy/config/legacy-project-ref.errors.ts"; import { legacySeedBucketsRun } from "../../../shared/legacy-seed-buckets.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../../shared/legacy-local-gateway-http-client.ts"; import { legacySeedBuckets } from "./buckets.handler.ts"; import type { LegacyBucketsFlags } from "./buckets.command.ts"; import { LegacyPlatformApi } from "../../../../legacy/auth/legacy-platform-api.service.ts"; @@ -44,6 +55,95 @@ interface MockRoute { } const DEFAULT_FLAGS: LegacyBucketsFlags = { linked: false, local: true, projectRef: Option.none() }; +const fixturePath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); + +interface FixtureState { + readonly writes: ReadonlyArray<{ readonly path: string; readonly contents: string | Uint8Array }>; + readonly directories: ReadonlyArray<string>; + readonly symlinks: ReadonlyArray<{ readonly target: string; readonly path: string }>; + readonly chmods: ReadonlyArray<{ readonly path: string; readonly mode: number }>; +} +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function formatCause(cause: Cause.Cause<unknown>) { + return Formatter.formatJson(cause); +} + +interface FixtureBuilder { + readonly write: (path: string, contents: string | Uint8Array) => void; + readonly mkdir: (path: string) => void; + readonly symlink: (target: string, path: string) => void; + readonly chmod: (path: string, mode: number) => void; + readonly state: () => FixtureState; +} + +function makeFixtureBuilder(): FixtureBuilder { + const writes: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; + const directories: Array<string> = []; + const symlinks: Array<{ readonly target: string; readonly path: string }> = []; + const chmods: Array<{ readonly path: string; readonly mode: number }> = []; + return { + write: (path, contents) => writes.push({ path, contents }), + mkdir: (path) => directories.push(path), + symlink: (target, path) => symlinks.push({ target, path }), + chmod: (path, mode) => chmods.push({ path, mode }), + state: () => ({ + writes, + directories, + symlinks, + chmods, + }), + }; +} + +function fixtureState(build: (fixture: FixtureBuilder) => void): FixtureState { + const fixture = makeFixtureBuilder(); + build(fixture); + return fixture.state(); +} + +function flushFixtureWrites(state: FixtureState) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const path of state.directories) { + yield* fs.makeDirectory(path, { recursive: true }); + } + for (const write of state.writes) { + yield* fs.makeDirectory(path.dirname(write.path), { recursive: true }); + if (typeof write.contents === "string") yield* fs.writeFileString(write.path, write.contents); + else yield* fs.writeFile(write.path, write.contents); + } + for (const symlink of state.symlinks) { + yield* fs.symlink(symlink.target, symlink.path); + } + for (const chmod of state.chmods) { + yield* fs.chmod(chmod.path, chmod.mode); + } + }); +} + +function createFifo(path: string) { + const effect: Effect.Effect< + void, + PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner + > = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner.exitCode(ChildProcess.make("mkfifo", [path])); + if (exitCode !== 0) { + return yield* PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "mkfifo", + pathOrDescriptor: path, + description: `mkfifo exited with code ${exitCode}`, + }); + } + }); + return effect; +} function setupLegacySeedBuckets( workdir: string, @@ -58,6 +158,10 @@ function setupLegacySeedBuckets( readonly pipedAnswers?: ReadonlyArray<string>; readonly args?: ReadonlyArray<string>; readonly yes?: boolean; + /** Explicit shell environment visible to Config/LegacyViperEnv in this scenario. */ + readonly env?: Readonly<Record<string, string | undefined>>; + /** Declarative filesystem fixture operations materialized by the test layer. */ + readonly fixtures?: FixtureState; /** Project ref returned by loadProjectRef for --linked tests. */ readonly projectRef?: string; /** API keys response for Management API mock. */ @@ -73,16 +177,27 @@ function setupLegacySeedBuckets( readonly apiKeysFail?: HttpClientError.HttpClientError; }, ) { + const fixture = makeFixtureBuilder(); if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); + fixture.write(join(workdir, "supabase", "config.toml"), opts.toml); } for (const [rel, content] of Object.entries(opts.files ?? {})) { const abs = join(workdir, rel); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); + fixture.write(abs, content); } + const configuredFixtures = opts.fixtures ?? { + writes: [], + directories: [], + symlinks: [], + chmods: [], + }; + const fixtures: FixtureState = { + writes: [...fixture.state().writes, ...configuredFixtures.writes], + directories: [...fixture.state().directories, ...configuredFixtures.directories], + symlinks: [...fixture.state().symlinks, ...configuredFixtures.symlinks], + chmods: [...fixture.state().chmods, ...configuredFixtures.chmods], + }; const out = mockOutput({ format: opts.format ?? "text", @@ -104,7 +219,7 @@ function setupLegacySeedBuckets( let body: unknown; if (reqBody._tag === "Uint8Array") { try { - body = JSON.parse(new TextDecoder().decode(reqBody.body)); + body = decodeJson(new TextDecoder().decode(reqBody.body)); } catch { body = undefined; } @@ -130,6 +245,10 @@ function setupLegacySeedBuckets( const telemetry = mockLegacyTelemetryStateTracked(); const linkedCache = mockLegacyLinkedProjectCacheTracked(); + const env: Record<string, string> = {}; + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (value !== undefined) env[key] = value; + } const projectRefRef = opts.projectRef ?? LEGACY_VALID_REF; const projectRefLayer = Layer.succeed(LegacyProjectRefResolver, { @@ -186,9 +305,12 @@ function setupLegacySeedBuckets( const layer = Layer.mergeAll( out.layer, httpLayer, + legacyLocalGatewayHttpClientTestLayer(httpLayer), telemetry.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })), + Layer.effectDiscard(flushFixtureWrites(fixtures)).pipe(Layer.provide(BunServices.layer)), // Seed-bucket prompts model an interactive user answering via `confirm`. mockTty({ stdinIsTty: true, stdoutIsTty: false }), mockStdin(true, opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined), @@ -553,15 +675,18 @@ describe("legacy seed buckets", () => { // a generic text/plain by extension (objects.go:77-108). A PNG named `.txt` // must upload as image/png (bytes win), and a JSON text file refines to // application/json via its extension. - mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); - // Real PNG magic bytes — written raw (a UTF-8 string would mangle 0x89). - writeFileSync( - join(tmp.current, "supabase", "assets", "logo.txt"), - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]), - ); - writeFileSync(join(tmp.current, "supabase", "assets", "data.json"), '{"a":1}'); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets")); + // Real PNG magic bytes — written raw (a UTF-8 string would mangle 0x89). + fixture.write( + join(tmp.current, "supabase", "assets", "logo.txt"), + Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]), + ); + fixture.write(join(tmp.current, "supabase", "assets", "data.json"), '{"a":1}'); + }); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -582,11 +707,14 @@ describe("legacy seed buckets", () => { it.live("resolves an absolute objects_path as-is (Go IsAbs guard)", () => { const absRoot = join(tmp.current, "external-assets"); - mkdirSync(absRoot, { recursive: true }); - writeFileSync(join(absRoot, "a.txt"), "hello"); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(absRoot); + fixture.write(join(absRoot, "a.txt"), "hello"); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { // An absolute objects_path is left untouched — no supabase/ prefix. toml: `[storage.buckets.images]\npublic = true\nobjects_path = "${absRoot}"\n`, + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -770,7 +898,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "Invalid config for auth.jwt_secret. Must be at least 16 characters", ); // Validation fails before any Storage call. @@ -796,7 +924,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("invalid size"); + expect(Formatter.formatJson(exit)).toContain("invalid size"); // No list/create happened — validation precedes every Storage side effect. expect(requests).toHaveLength(0); }); @@ -812,7 +940,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("invalid size"); + expect(Formatter.formatJson(exit)).toContain("invalid size"); expect(requests).toHaveLength(0); }); }); @@ -831,7 +959,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("invalid size"); + expect(Formatter.formatJson(exit)).toContain("invalid size"); expect(requests).toHaveLength(0); }); }); @@ -846,7 +974,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("invalid size"); + expect(Formatter.formatJson(exit)).toContain("invalid size"); expect(requests).toHaveLength(0); }); }); @@ -920,7 +1048,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - const s = JSON.stringify(exit); + const s = Formatter.formatJson(exit); expect(s).toContain("Another process may be listening on the configured API port 7654"); expect(s).toContain("lsof -nP -iTCP:7654 -sTCP:LISTEN"); }); @@ -936,7 +1064,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).not.toContain("Another process may be listening"); + expect(Formatter.formatJson(exit)).not.toContain("Another process may be listening"); }); }); @@ -957,7 +1085,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - const s = JSON.stringify(exit); + const s = Formatter.formatJson(exit); expect(s).toContain("configured API port 9999"); expect(s).not.toContain("port 7654"); }); @@ -971,7 +1099,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).not.toContain("Another process may be listening"); + expect(Formatter.formatJson(exit)).not.toContain("Another process may be listening"); }); }); @@ -989,7 +1117,7 @@ describe("legacy seed buckets", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).not.toContain("Another process may be listening"); + expect(Formatter.formatJson(exit)).not.toContain("Another process may be listening"); }); }); @@ -1005,7 +1133,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("failed to parse response body"); + expect(Formatter.formatJson(exit)).toContain("failed to parse response body"); }); }); @@ -1026,14 +1154,9 @@ describe("legacy seed buckets", () => { }); it.live("falls back to the default host when external_url is empty", () => { - // Clear both host overrides so legacyGetHostname resolves to loopback - // deterministically, regardless of the test environment's DOCKER_HOST. - const previousServices = process.env["SUPABASE_SERVICES_HOSTNAME"]; - const previousDocker = process.env["DOCKER_HOST"]; - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - delete process.env["DOCKER_HOST"]; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api]\nexternal_url = ""\n[storage.buckets.images]\npublic = true\n', + env: {}, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, @@ -1043,22 +1166,7 @@ describe("legacy seed buckets", () => { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.every((r) => r.url.startsWith("http://127.0.0.1:54321"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousServices === undefined) { - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - } else { - process.env["SUPABASE_SERVICES_HOSTNAME"] = previousServices; - } - if (previousDocker === undefined) { - delete process.env["DOCKER_HOST"]; - } else { - process.env["DOCKER_HOST"] = previousDocker; - } - }), - ), - ); + }); }); it.live("tolerates bucket entries with a missing field (Go zero value)", () => { @@ -1095,7 +1203,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("failed to parse response body"); + expect(Formatter.formatJson(exit)).toContain("failed to parse response body"); expect(requests.some((r) => r.method === "POST")).toBe(false); }); }); @@ -1127,7 +1235,7 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Error status 201"); + expect(Formatter.formatJson(exit)).toContain("Error status 201"); }); }); @@ -1157,10 +1265,9 @@ describe("legacy seed buckets", () => { ); it.live("builds an https base URL with a host override when tls is enabled", () => { - const previousHost = process.env["SUPABASE_SERVICES_HOSTNAME"]; - process.env["SUPABASE_SERVICES_HOSTNAME"] = "docker.host"; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[api]\nport = 7654\n[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", + env: { SUPABASE_SERVICES_HOSTNAME: "docker.host" }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, @@ -1170,24 +1277,13 @@ describe("legacy seed buckets", () => { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.every((r) => r.url.startsWith("https://docker.host:7654"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousHost === undefined) { - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - } else { - process.env["SUPABASE_SERVICES_HOSTNAME"] = previousHost; - } - }), - ), - ); + }); }); it.live("brackets an IPv6 local host when building the gateway URL", () => { - const previousHost = process.env["SUPABASE_SERVICES_HOSTNAME"]; - process.env["SUPABASE_SERVICES_HOSTNAME"] = "::1"; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", + env: { SUPABASE_SERVICES_HOSTNAME: "::1" }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, @@ -1198,26 +1294,13 @@ describe("legacy seed buckets", () => { expect(Exit.isSuccess(exit)).toBe(true); // `net.JoinHostPort` brackets IPv6: http://[::1]:54321, not http://::1:54321. expect(requests.every((r) => r.url.startsWith("http://[::1]:54321"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousHost === undefined) { - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - } else { - process.env["SUPABASE_SERVICES_HOSTNAME"] = previousHost; - } - }), - ), - ); + }); }); it.live("falls back to the TCP Docker daemon host when only DOCKER_HOST is set", () => { - const previousServices = process.env["SUPABASE_SERVICES_HOSTNAME"]; - const previousDocker = process.env["DOCKER_HOST"]; - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - process.env["DOCKER_HOST"] = "tcp://docker.internal:2375"; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", + env: { DOCKER_HOST: "tcp://docker.internal:2375" }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, @@ -1229,31 +1312,19 @@ describe("legacy seed buckets", () => { // `GetHostname` dials the TCP daemon host, not loopback, when only // DOCKER_HOST is set (misc.go:305-310). expect(requests.every((r) => r.url.startsWith("http://docker.internal:54321"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousServices === undefined) { - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - } else { - process.env["SUPABASE_SERVICES_HOSTNAME"] = previousServices; - } - if (previousDocker === undefined) { - delete process.env["DOCKER_HOST"]; - } else { - process.env["DOCKER_HOST"] = previousDocker; - } - }), - ), - ); + }); }); it.live("skips non-regular files during the object walk", () => { // A FIFO is neither a regular file nor a directory, exercising the skip path. - mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); - execFileSync("mkfifo", [join(tmp.current, "supabase", "assets", "pipe")]); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets")); + fixture.write(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + }); + const fifoPath = join(tmp.current, "supabase", "assets", "pipe"); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1261,20 +1332,24 @@ describe("legacy seed buckets", () => { ], }); return Effect.gen(function* () { - const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); + yield* createFifo(fifoPath); + const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain("Skipping non-regular file: supabase/assets/pipe"); const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); expect(uploads).toHaveLength(1); - }); + }).pipe(Effect.provide(layer)); }); it.live("skips a dangling symlink without failing (Go isUploadableEntry parity)", () => { - mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); - symlinkSync("./does-not-exist", join(tmp.current, "supabase", "assets", "dangling")); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets")); + fixture.write(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + fixture.symlink("./does-not-exist", join(tmp.current, "supabase", "assets", "dangling")); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1296,13 +1371,16 @@ describe("legacy seed buckets", () => { // must never be uploaded as seeded objects — they are never even attempted, // covering the "silently becomes a public object" failure mode, not just // an upload-time abort. - mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); - writeFileSync(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); - writeFileSync(join(tmp.current, "supabase", "assets", "Thumbs.db"), "junk"); - writeFileSync(join(tmp.current, "supabase", "assets", "desktop.ini"), "junk"); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets")); + fixture.write(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + fixture.write(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); + fixture.write(join(tmp.current, "supabase", "assets", "Thumbs.db"), "junk"); + fixture.write(join(tmp.current, "supabase", "assets", "desktop.ini"), "junk"); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1329,9 +1407,11 @@ describe("legacy seed buckets", () => { // allowed_mime_types server-side (that's real Storage-service behavior), so // this doesn't simulate the 415 abort itself — it asserts the junk file is // skipped and never uploaded, while the real image file still uploads. - mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "assets", "logo.png"), "fake-png-bytes"); - writeFileSync(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets")); + fixture.write(join(tmp.current, "supabase", "assets", "logo.png"), "fake-png-bytes"); + fixture.write(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: [ "[storage.buckets.images]", @@ -1339,6 +1419,7 @@ describe("legacy seed buckets", () => { 'allowed_mime_types = ["image/png"]', 'objects_path = "./assets"', ].join("\n"), + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1362,10 +1443,13 @@ describe("legacy seed buckets", () => { it.live("skips a .DS_Store file when objects_path points directly at it (CLI-1950)", () => { // Covers collectFiles' single-file branch: objects_path resolves directly to // a junk-named file rather than a directory. - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", ".DS_Store"), "junk"); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase")); + fixture.write(join(tmp.current, "supabase", ".DS_Store"), "junk"); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./.DS_Store"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, @@ -1393,18 +1477,28 @@ describe("legacy seed buckets", () => { // The real unreadable file lives OUTSIDE the walked tree: a plain regular file // inside assets/ would be queued without an open-probe and would legitimately // abort, so only the symlink may reach the unreadable target. - mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); - mkdirSync(join(tmp.current, "supabase", "private"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets")); + fixture.mkdir(join(tmp.current, "supabase", "private")); + fixture.write(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + }); const secret = join(tmp.current, "supabase", "private", "secret.txt"); - writeFileSync(secret, "top secret"); - chmodSync(secret, 0o000); - symlinkSync( - "../private/secret.txt", - join(tmp.current, "supabase", "assets", "link-to-secret"), - ); + const secretFixture = fixtureState((fixture) => { + fixture.write(secret, "top secret"); + fixture.chmod(secret, 0o000); + fixture.symlink( + "../private/secret.txt", + join(tmp.current, "supabase", "assets", "link-to-secret"), + ); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + fixtures: { + writes: [...fixtures.writes, ...secretFixture.writes], + directories: [...fixtures.directories, ...secretFixture.directories], + symlinks: [...fixtures.symlinks, ...secretFixture.symlinks], + chmods: [...fixtures.chmods, ...secretFixture.chmods], + }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1431,12 +1525,15 @@ describe("legacy seed buckets", () => { it.live( "does not descend into a symlinked directory (Go does not follow nested symlinks)", () => { - mkdirSync(join(tmp.current, "supabase", "assets", "realdir"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); - writeFileSync(join(tmp.current, "supabase", "assets", "realdir", "c.txt"), "world"); - symlinkSync("./realdir", join(tmp.current, "supabase", "assets", "linkdir")); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "assets", "realdir")); + fixture.write(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); + fixture.write(join(tmp.current, "supabase", "assets", "realdir", "c.txt"), "world"); + fixture.symlink("./realdir", join(tmp.current, "supabase", "assets", "linkdir")); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1464,11 +1561,14 @@ describe("legacy seed buckets", () => { // `io/fs.WalkDir` follows a symlinked ROOT ("if root itself is a // symbolic link, its target will be walked"); only NESTED symlinks are // skipped. fs.stat on the root follows the link, so the target dir is walked. - mkdirSync(join(tmp.current, "supabase", "real-assets"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "real-assets", "a.txt"), "hello"); - symlinkSync("./real-assets", join(tmp.current, "supabase", "linked-assets")); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "real-assets")); + fixture.write(join(tmp.current, "supabase", "real-assets", "a.txt"), "hello"); + fixture.symlink("./real-assets", join(tmp.current, "supabase", "linked-assets")); + }); const { layer, out, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./linked-assets"\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/object/", body: {} }, @@ -1641,7 +1741,7 @@ describe("legacy seed buckets", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(formatCause(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", ); } @@ -1715,7 +1815,7 @@ describe("legacy seed buckets", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); // `tenant.GetApiKeys` → errMissingKey, before NewStorageAPI. - expect(JSON.stringify(exit)).toContain("Anon key not found."); + expect(Formatter.formatJson(exit)).toContain("Anon key not found."); expect(requests.some((r) => r.url.includes("/storage/v1/"))).toBe(false); }); }); @@ -1739,7 +1839,7 @@ describe("legacy seed buckets", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - const json = JSON.stringify(exit); + const json = Formatter.formatJson(exit); expect(json).toContain("LegacyStorageAuthTokenError"); expect(json).toContain("Authorization failed for the access token and project ref pair"); expect(json).not.toContain("unexpected get api keys status"); @@ -1782,12 +1882,11 @@ describe("legacy seed buckets", () => { }); it.live("--linked uses SUPABASE_AUTH_SERVICE_ROLE_KEY env var when set", () => { - const prevKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; const flags: LegacyBucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: LEGACY_VALID_REF, + env: { SUPABASE_AUTH_SERVICE_ROLE_KEY: "env-service-role-key" }, args: ["seed", "buckets", "--linked"], routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -1798,17 +1897,7 @@ describe("legacy seed buckets", () => { const exit = yield* legacySeedBuckets(flags).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.every((r) => r.headers["apikey"] === "env-service-role-key")).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prevKey === undefined) { - delete process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - } else { - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = prevKey; - } - }), - ), - ); + }); }); it.live("upserts analytics buckets when analytics.enabled and --linked", () => { @@ -1922,10 +2011,9 @@ describe("legacy seed buckets", () => { // does not throw, and that the gateway is called with https:// URLs — matching // the existing "builds an https base URL" test but going through the full // CA-resolution branch in the handler. - const previousHost = process.env["SUPABASE_SERVICES_HOSTNAME"]; - process.env["SUPABASE_SERVICES_HOSTNAME"] = "localhost"; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: "[api]\nport = 54321\n[api.tls]\nenabled = true\n[storage.buckets.images]\npublic = true\n", + env: { SUPABASE_SERVICES_HOSTNAME: "localhost" }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, @@ -1935,17 +2023,7 @@ describe("legacy seed buckets", () => { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.every((r) => r.url.startsWith("https://localhost:54321"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previousHost === undefined) { - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - } else { - process.env["SUPABASE_SERVICES_HOSTNAME"] = previousHost; - } - }), - ), - ); + }); }); it.live("reads cert_path and key_path from disk when both api.tls paths are set", () => { @@ -1953,11 +2031,14 @@ describe("legacy seed buckets", () => { // for the handler to succeed (Go validateLocalKongTls parity). const certContent = "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n"; const keyContent = "-----BEGIN PRIVATE KEY-----\nZHVtbXk=\n-----END PRIVATE KEY-----\n"; - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "custom-ca.crt"), certContent); - writeFileSync(join(tmp.current, "supabase", "custom-ca.key"), keyContent); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase")); + fixture.write(join(tmp.current, "supabase", "custom-ca.crt"), certContent); + fixture.write(join(tmp.current, "supabase", "custom-ca.key"), keyContent); + }); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api]\nport = 54321\n[api.tls]\nenabled = true\ncert_path = "custom-ca.crt"\nkey_path = "custom-ca.key"\n[storage.buckets.docs]\npublic = false\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "docs" } }, @@ -1982,11 +2063,14 @@ describe("legacy seed buckets", () => { // the literal /tmp path it would fail to read and error out. const certContent = "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n"; const keyContent = "-----BEGIN PRIVATE KEY-----\nZHVtbXk=\n-----END PRIVATE KEY-----\n"; - mkdirSync(join(tmp.current, "supabase", "tmp"), { recursive: true }); - writeFileSync(join(tmp.current, "supabase", "tmp", "kong.crt"), certContent); - writeFileSync(join(tmp.current, "supabase", "tmp", "kong.key"), keyContent); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase", "tmp")); + fixture.write(join(tmp.current, "supabase", "tmp", "kong.crt"), certContent); + fixture.write(join(tmp.current, "supabase", "tmp", "kong.key"), keyContent); + }); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api]\nport = 54321\n[api.tls]\nenabled = true\ncert_path = "/tmp/kong.crt"\nkey_path = "/tmp/kong.key"\n[storage.buckets.docs]\npublic = false\n', + fixtures, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "docs" } }, @@ -2097,9 +2181,9 @@ describe("legacy seed buckets", () => { return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - // JSON.stringify escapes backslashes once more, so \\w in the message + // Formatter output preserves the escaped backslashes in the diagnostic message. // becomes \\\\w in the JSON string — use the double-escaped form. - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "Invalid Bucket name: bad/name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (^(\\\\w|!|-|\\\\.|\\\\*|'|\\\\(|\\\\)| |&|\\\\$|@|=|;|:|\\\\+|,|\\\\?)*$)", ); // Validation fails before any Storage call. @@ -2133,11 +2217,7 @@ describe("legacy seed buckets", () => { // Fix 3 — SUPABASE_AUTH_JWT_SECRET / SUPABASE_AUTH_SERVICE_ROLE_KEY for local it.live("local run: SUPABASE_AUTH_JWT_SECRET overrides auth.jwt_secret", () => { - const prevJwt = process.env["SUPABASE_AUTH_JWT_SECRET"]; - const prevKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; // Use a custom secret; the derived JWT will differ from the default secret's JWT. - process.env["SUPABASE_AUTH_JWT_SECRET"] = "custom-jwt-secret-at-least-32-chars-long!"; - delete process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: [ "[auth]", @@ -2145,6 +2225,7 @@ describe("legacy seed buckets", () => { "[storage.buckets.media]", "public = true", ].join("\n"), + env: { SUPABASE_AUTH_JWT_SECRET: "custom-jwt-secret-at-least-32-chars-long!" }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "media" } }, @@ -2157,29 +2238,10 @@ describe("legacy seed buckets", () => { expect( requests.every((r) => (r.headers["authorization"] ?? "").startsWith("Bearer ey")), ).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prevJwt === undefined) { - delete process.env["SUPABASE_AUTH_JWT_SECRET"]; - } else { - process.env["SUPABASE_AUTH_JWT_SECRET"] = prevJwt; - } - if (prevKey === undefined) { - delete process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - } else { - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = prevKey; - } - }), - ), - ); + }); }); it.live("local run: SUPABASE_AUTH_SERVICE_ROLE_KEY overrides auth.service_role_key", () => { - const prevJwt = process.env["SUPABASE_AUTH_JWT_SECRET"]; - const prevKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-local-service-role-key"; - delete process.env["SUPABASE_AUTH_JWT_SECRET"]; const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: [ "[auth]", @@ -2187,6 +2249,7 @@ describe("legacy seed buckets", () => { "[storage.buckets.media]", "public = true", ].join("\n"), + env: { SUPABASE_AUTH_SERVICE_ROLE_KEY: "env-local-service-role-key" }, routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "media" } }, @@ -2198,91 +2261,92 @@ describe("legacy seed buckets", () => { expect(requests.every((r) => r.headers["apikey"] === "env-local-service-role-key")).toBe( true, ); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (prevJwt === undefined) { - delete process.env["SUPABASE_AUTH_JWT_SECRET"]; - } else { - process.env["SUPABASE_AUTH_JWT_SECRET"] = prevJwt; - } - if (prevKey === undefined) { - delete process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; - } else { - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = prevKey; - } - }), - ), - ); + }); }); // Fix 5 — validate api.tls cert/key pairing before seeding it.live("fails when cert_path is set but key_path is missing", () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "custom-ca.crt"), - "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n", - ); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase")); + fixture.write( + join(tmp.current, "supabase", "custom-ca.crt"), + "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n", + ); + }); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api.tls]\nenabled = true\ncert_path = "custom-ca.crt"\n[storage.buckets.docs]\npublic = false\n', + fixtures, routes: [], }); return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Missing required field in config: api.tls.key_path"); + expect(Formatter.formatJson(exit)).toContain( + "Missing required field in config: api.tls.key_path", + ); expect(requests).toHaveLength(0); }); }); it.live("fails when key_path is set but cert_path is missing", () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); - writeFileSync( - join(tmp.current, "supabase", "custom-ca.key"), - "-----BEGIN PRIVATE KEY-----\nZHVtbXk=\n-----END PRIVATE KEY-----\n", - ); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase")); + fixture.write( + join(tmp.current, "supabase", "custom-ca.key"), + "-----BEGIN PRIVATE KEY-----\nZHVtbXk=\n-----END PRIVATE KEY-----\n", + ); + }); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api.tls]\nenabled = true\nkey_path = "custom-ca.key"\n[storage.buckets.docs]\npublic = false\n', + fixtures, routes: [], }); return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Missing required field in config: api.tls.cert_path"); + expect(Formatter.formatJson(exit)).toContain( + "Missing required field in config: api.tls.cert_path", + ); expect(requests).toHaveLength(0); }); }); it.live("fails when cert_path points to an unreadable file", () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase")); + }); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api.tls]\nenabled = true\ncert_path = "missing-cert.crt"\nkey_path = "missing-key.key"\n[storage.buckets.docs]\npublic = false\n', + fixtures, routes: [], }); return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("failed to read TLS cert:"); + expect(Formatter.formatJson(exit)).toContain("failed to read TLS cert:"); expect(requests).toHaveLength(0); }); }); it.live("fails when key_path points to an unreadable file", () => { - mkdirSync(join(tmp.current, "supabase"), { recursive: true }); + const fixtures = fixtureState((fixture) => { + fixture.mkdir(join(tmp.current, "supabase")); + fixture.write( + join(tmp.current, "supabase", "custom-ca.crt"), + "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n", + ); + }); // cert is readable, key is missing. - writeFileSync( - join(tmp.current, "supabase", "custom-ca.crt"), - "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n", - ); const { layer, requests } = setupLegacySeedBuckets(tmp.current, { toml: '[api.tls]\nenabled = true\ncert_path = "custom-ca.crt"\nkey_path = "missing-key.key"\n[storage.buckets.docs]\npublic = false\n', + fixtures, routes: [], }); return Effect.gen(function* () { const exit = yield* legacySeedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("failed to read TLS key:"); + expect(Formatter.formatJson(exit)).toContain("failed to read TLS key:"); expect(requests).toHaveLength(0); }); }); diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts index 396f40b4b7..8fdfa75b6b 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.ts @@ -1,4 +1,4 @@ -import * as nodePath from "node:path"; +import type { Path } from "effect"; /** * Pure path helper for `seed buckets` object upload, ported from @@ -20,14 +20,16 @@ import * as nodePath from "node:path"; * to forward slashes (`filepath.ToSlash`) for the remote key. */ export function legacyBucketObjectKey( + path: Path.Path, + posixPath: Path.Path, bucketName: string, objectsPath: string, filePath: string, ): string { - const relPath = nodePath.relative(objectsPath, filePath); + const relPath = path.relative(objectsPath, filePath); if (relPath === "") { - return nodePath.posix.join(bucketName, nodePath.basename(filePath)); + return posixPath.join(bucketName, posixPath.basename(filePath)); } - const relPosix = relPath.split(nodePath.sep).join(nodePath.posix.sep); - return nodePath.posix.join(bucketName, relPosix); + const relPosix = relPath.split(path.sep).join(posixPath.sep); + return posixPath.join(bucketName, relPosix); } diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts index 1b3e60842e..0a05ee857d 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.upload.unit.test.ts @@ -1,25 +1,47 @@ +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; +import { Effect, Path } from "effect"; import { legacyBucketObjectKey } from "./buckets.upload.ts"; describe("legacyBucketObjectKey", () => { - it("maps a single-file objects_path to <bucket>/<basename>", () => { - expect(legacyBucketObjectKey("docs", "assets/file.pdf", "assets/file.pdf")).toBe( - "docs/file.pdf", - ); - }); + it.effect("maps a single-file objects_path to <bucket>/<basename>", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const posixPath = yield* Path.Path.pipe(Effect.provide(Path.layer)); + expect( + legacyBucketObjectKey(path, posixPath, "docs", "assets/file.pdf", "assets/file.pdf"), + ).toBe("docs/file.pdf"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("maps a direct child to <bucket>/<name>", () => { - expect(legacyBucketObjectKey("docs", "assets", "assets/a.txt")).toBe("docs/a.txt"); - }); + it.effect("maps a direct child to <bucket>/<name>", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const posixPath = yield* Path.Path.pipe(Effect.provide(Path.layer)); + expect(legacyBucketObjectKey(path, posixPath, "docs", "assets", "assets/a.txt")).toBe( + "docs/a.txt", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("maps a nested file to <bucket>/<relative-posix-path>", () => { - expect(legacyBucketObjectKey("docs", "assets", "assets/sub/dir/b.txt")).toBe( - "docs/sub/dir/b.txt", - ); - }); + it.effect("maps a nested file to <bucket>/<relative-posix-path>", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const posixPath = yield* Path.Path.pipe(Effect.provide(Path.layer)); + expect(legacyBucketObjectKey(path, posixPath, "docs", "assets", "assets/sub/dir/b.txt")).toBe( + "docs/sub/dir/b.txt", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("normalises a leading ./ in objects_path", () => { - expect(legacyBucketObjectKey("docs", "./assets", "assets/a.txt")).toBe("docs/a.txt"); - }); + it.effect("normalises a leading ./ in objects_path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const posixPath = yield* Path.Path.pipe(Effect.provide(Path.layer)); + expect(legacyBucketObjectKey(path, posixPath, "docs", "./assets", "assets/a.txt")).toBe( + "docs/a.txt", + ); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/legacy/commands/seed/seed.command.ts b/apps/cli/src/legacy/commands/seed/seed.command.ts index 32b069811c..31539f3564 100644 --- a/apps/cli/src/legacy/commands/seed/seed.command.ts +++ b/apps/cli/src/legacy/commands/seed/seed.command.ts @@ -8,6 +8,6 @@ export const legacySeedCommand = Command.make("seed").pipe( Command.withShortDescription("Seed a Supabase project"), // Persistent `--linked`/`--local` (Go `seedCmd.PersistentFlags()`), accepted // before or after the subcommand. See `seed.flags.ts`. - Command.withGlobalFlags([LegacySeedLinkedFlag, LegacySeedLocalFlag]), Command.withSubcommands([legacyBucketsCommand]), + Command.withGlobalFlags([LegacySeedLinkedFlag, LegacySeedLocalFlag]), ); diff --git a/apps/cli/src/legacy/commands/services/services.handler.ts b/apps/cli/src/legacy/commands/services/services.handler.ts index 7492b351dc..7c5d596b5c 100644 --- a/apps/cli/src/legacy/commands/services/services.handler.ts +++ b/apps/cli/src/legacy/commands/services/services.handler.ts @@ -188,11 +188,9 @@ export const legacyServices = Effect.fn("legacy.services")(function* (_flags: Le const goOutput = Option.getOrUndefined(legacyOutput); if (goOutput === "env") { - return yield* Effect.fail( - new LegacyServicesEnvNotSupportedError({ - message: "--output env flag is not supported", - }), - ); + return yield* new LegacyServicesEnvNotSupportedError({ + message: "--output env flag is not supported", + }); } if (goOutput === "json") { diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 71bf0dac62..1af3ac1bef 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -1,17 +1,28 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { CliOutput, Command } from "effect/unstable/cli"; -import { Stdio } from "effect"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Formatter, + Layer, + Option, + Predicate, + Redacted, + Schema, + Stdio, + Path, +} from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { INVALID_PROJECT_REF_MESSAGE } from "../../config/legacy-project-ref.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { LEGACY_GLOBAL_FLAGS, LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { mockAnalytics, mockOutput, @@ -20,6 +31,7 @@ import { processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; +import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { listLocalServiceVersions, postgresImageForDbMajorVersion, @@ -40,6 +52,17 @@ if (LOCAL_POSTGRES_SERVICE === undefined) { } const LOCAL_POSTGRES_VERSION = LOCAL_POSTGRES_SERVICE.local; +const legacyTestConfigProvider = ConfigProvider.fromEnv({ preserveEmptyStrings: true }); +const legacyTestViperLayer = makeLegacyViperEnvLayer(legacyTestConfigProvider); +const tempRoot = useLegacyTempWorkdir("supabase-services-"); + +const ServiceRowsSchema = Schema.Array( + Schema.Struct({ + name: Schema.String, + local: Schema.String, + remote: Schema.String, + }), +); function setup( opts: { @@ -63,6 +86,8 @@ function setup( cachedRefs, layer: Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(legacyTestConfigProvider), + legacyTestViperLayer, FetchHttpClient.layer, out.layer, telemetry.layer, @@ -117,28 +142,22 @@ const legacyTestRoot = Command.make("supabase").pipe( Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), ); -function makeProjectWithConfig(config: string): string { - const workdir = mkdtempSync(join(tmpdir(), "supabase-services-config-")); - const configDir = join(workdir, "supabase"); - mkdirSync(configDir, { recursive: true }); - writeFileSync(join(configDir, "config.toml"), config); - return workdir; +function writeProjectFile(workdir: string, relativePath: string, content: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(workdir, "supabase", relativePath); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, content); + }).pipe(Effect.provide(BunServices.layer)); } -function makeProjectWithConfigFiles(opts: { toml: string; json: string }): string { - const workdir = makeProjectWithConfig(opts.toml); - writeFileSync(join(workdir, "supabase", "config.json"), opts.json); - return workdir; -} - -function makeProjectWithDbMajorVersion(majorVersion: number): string { - return makeProjectWithConfig(`[db]\nmajor_version = ${majorVersion}\n`); -} - -function writeTempFile(workdir: string, name: string, content: string): void { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, name), content); +function makeProjectDirectory(workdir: string, relativePath: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase", relativePath), { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); } function postgresVersionForDbMajorVersion(majorVersion: number): string { @@ -158,60 +177,58 @@ function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure)).toBe(true); if (Option.isSome(failure)) { - expect((failure.value as { _tag: string })._tag).toBe(tag); + expect(Predicate.isTagged(failure.value, tag)).toBe(true); } } describe("legacy services", () => { - it.effect("runs tokenless local service listing through command wiring", () => - Effect.tryPromise({ - try: async () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const args = ["services"]; - const layer = Layer.mergeAll( - BunServices.layer, - processControlLayer, - CliOutput.layer(textCliOutputFormatter()), - out.layer, - analytics.layer, - processEnvLayer({ SUPABASE_HOME: workdir, SUPABASE_NO_KEYRING: "1" }), - mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), - mockTty({ stdinIsTty: false, stdoutIsTty: false }), - Stdio.layerTest({ args: Effect.succeed(args) }), - Layer.succeed( - TelemetryRuntime, - TelemetryRuntime.of({ - configDir: join(workdir, ".supabase"), - tracesDir: join(workdir, ".supabase", "traces"), - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity: makeTelemetryIdentity(undefined), - isFirstRun: false, - isTty: false, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }), - ), - ); - - await Effect.runPromise( - Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(args).pipe( - Effect.provide(layer), - ) as Effect.Effect<void>, - ); - - expect(out.stdoutText).toContain("supabase/postgres"); - expect(out.stdoutText).toContain("supabase/gotrue"); - expect(out.stderrText).not.toContain("Access token not provided"); - }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + it.live("runs tokenless local service listing through command wiring", () => + Effect.gen(function* () { + const workdir = tempRoot.current; + const path = yield* Path.Path; + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const args = ["services"]; + const layer = Layer.mergeAll( + BunServices.layer, + ConfigProvider.layer(legacyTestConfigProvider), + legacyTestViperLayer, + processControlLayer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + analytics.layer, + processEnvLayer({ SUPABASE_HOME: workdir, SUPABASE_NO_KEYRING: "1" }), + mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Stdio.layerTest({ args: Effect.succeed(args) }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: path.join(workdir, ".supabase"), + tracesDir: path.join(workdir, ".supabase", "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(args).pipe( + Effect.provide(layer), + ); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stdoutText).toContain("supabase/gotrue"); + expect(out.stderrText).not.toContain("Access token not provided"); + }).pipe(Effect.provide(BunServices.layer)), ); it.live("prints the services table by default", () => { @@ -233,11 +250,9 @@ describe("legacy services", () => { return Effect.gen(function* () { yield* legacyServices({}).pipe(Effect.provide(layer)); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toHaveLength(10); expect(rows[0]).toMatchObject({ name: "supabase/postgres", @@ -247,52 +262,53 @@ describe("legacy services", () => { }); it.live("reports the configured Postgres version for local projects", () => { - const workdir = makeProjectWithDbMajorVersion(15); + const workdir = tempRoot.current; const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, "config.toml", "[db]\nmajor_version = 15\n"); yield* legacyServices({}).pipe(Effect.provide(layer)); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", local: postgresVersionForDbMajorVersion(15), }), ); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("ignores config.json and reads legacy config.toml for local image selection", () => { - const workdir = makeProjectWithConfigFiles({ - toml: "[db]\nmajor_version = 15\n", - json: JSON.stringify({ db: { major_version: 14 } }), - }); + const workdir = tempRoot.current; const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, "config.toml", "[db]\nmajor_version = 15\n"); + yield* writeProjectFile( + workdir, + "config.json", + Formatter.formatJson({ db: { major_version: 14 } }), + ); yield* legacyServices({}).pipe(Effect.provide(layer)); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", local: postgresVersionForDbMajorVersion(15), }), ); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("applies linked-project remote config overrides when choosing the local image", () => { - const workdir = makeProjectWithConfig(` + const workdir = tempRoot.current; + const config = ` [db] major_version = 17 @@ -301,59 +317,57 @@ project_id = "abcdefghijklmnopqrst" [remotes.linked.db] major_version = 15 -`); - writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); +`; const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, "config.toml", config); + yield* writeProjectFile(workdir, ".temp/project-ref", "abcdefghijklmnopqrst"); yield* legacyServices({}).pipe(Effect.provide(layer)); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", local: postgresVersionForDbMajorVersion(15), }), ); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("warns and skips the remote lookup for a malformed linked project ref", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); - writeTempFile(workdir, "project-ref", "not-a-valid-ref"); + const workdir = tempRoot.current; const { layer, out } = setup({ workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, ".temp/project-ref", "not-a-valid-ref"); yield* legacyServices({}).pipe(Effect.provide(layer)); expect(out.stderrText).toContain(INVALID_PROJECT_REF_MESSAGE); expect(out.stdoutText).toContain("supabase/postgres"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); // A token present doesn't bypass the format guard (the warning is // unconditional on login too) — same code path as the previous test, so this // isn't new branch coverage, just pinning that login state can't skip it. it.live("still warns on a malformed ref even when logged in", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); - writeTempFile(workdir, "project-ref", "not-a-valid-ref"); + const workdir = tempRoot.current; const { layer, out } = setup({ workdir, accessToken: "sbp_test-token" }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, ".temp/project-ref", "not-a-valid-ref"); yield* legacyServices({}).pipe(Effect.provide(layer)); expect(out.stderrText).toContain(INVALID_PROJECT_REF_MESSAGE); expect(out.stdoutText).toContain("supabase/postgres"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("fetches and merges remote versions for a valid ref when logged in", () => { - const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); - writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + const workdir = tempRoot.current; const server = Bun.serve({ port: 0, @@ -408,41 +422,33 @@ major_version = 15 }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, ".temp/project-ref", "abcdefghijklmnopqrst"); yield* legacyServices({}).pipe(Effect.provide(layer)); expect(out.stderrText).not.toContain(INVALID_PROJECT_REF_MESSAGE); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/postgres", remote: "17.6.1.200" }), ); - }).pipe( - Effect.ensuring( - Effect.promise(() => server.stop(true)).pipe( - Effect.andThen(Effect.sync(() => rmSync(workdir, { recursive: true, force: true }))), - ), - ), - ); + }).pipe(Effect.ensuring(Effect.promise(() => server.stop(true)))); }); it.live("reports pinned legacy temp service versions", () => { - const workdir = makeProjectWithDbMajorVersion(15); - writeTempFile(workdir, "postgres-version", "15.1.0.117\n"); - writeTempFile(workdir, "gotrue-version", "2.74.2\n"); - writeTempFile(workdir, "storage-version", "v1.28.0\n"); + const workdir = tempRoot.current; const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, "config.toml", "[db]\nmajor_version = 15\n"); + yield* writeProjectFile(workdir, ".temp/postgres-version", "15.1.0.117\n"); + yield* writeProjectFile(workdir, ".temp/gotrue-version", "2.74.2\n"); + yield* writeProjectFile(workdir, ".temp/storage-version", "v1.28.0\n"); yield* legacyServices({}).pipe(Effect.provide(layer)); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "supabase/postgres", local: "15.1.0.117" }), @@ -450,43 +456,43 @@ major_version = 15 expect.objectContaining({ name: "supabase/storage-api", local: "v1.28.0" }), ]), ); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("reports the Deno 1 edge-runtime image instead of the temp pin", () => { - const workdir = makeProjectWithConfig("[edge_runtime]\ndeno_version = 1\n"); - writeTempFile(workdir, "edge-runtime-version", "v9.9.9\n"); + const workdir = tempRoot.current; const { layer, out } = setup({ goOutput: Option.some("json"), workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, "config.toml", "[edge_runtime]\ndeno_version = 1\n"); + yield* writeProjectFile(workdir, ".temp/edge-runtime-version", "v9.9.9\n"); yield* legacyServices({}).pipe(Effect.provide(layer)); - const rows = JSON.parse(out.stdoutText) as Array<{ - name: string; - local: string; - remote: string; - }>; + const rows = yield* Schema.decodeEffect(Schema.fromJsonString(ServiceRowsSchema))( + out.stdoutText, + ); expect(rows).toContainEqual( expect.objectContaining({ name: "supabase/edge-runtime", local: "v1.68.4", }), ); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("prints config load errors and falls back to the default matrix", () => { - const workdir = makeProjectWithConfig("[db]\nmajor_version = "); - writeTempFile(workdir, "storage-version", "v9.9.9\n"); + const workdir = tempRoot.current; const { layer, out } = setup({ workdir }); return Effect.gen(function* () { + yield* writeProjectFile(workdir, "config.toml", "[db]\nmajor_version = "); + yield* writeProjectFile(workdir, ".temp/storage-version", "v9.9.9\n"); yield* legacyServices({}).pipe(Effect.provide(layer)); expect(out.stdoutText).toContain("supabase/postgres"); expect(out.stdoutText).not.toContain("v9.9.9"); expect(out.stderrText).not.toBe(""); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("emits structured JSON for --output pretty combined with --output-format json", () => { @@ -563,16 +569,16 @@ major_version = 15 it.live("warns to stderr when the project-ref file exists but cannot be read", () => { // A directory at the ref path makes `exists()` true but `readFileString()` fail // (EISDIR), exercising the READ-error branch distinct from "file absent". - const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); - mkdirSync(join(workdir, "supabase", ".temp", "project-ref"), { recursive: true }); + const workdir = tempRoot.current; const { layer, out } = setup({ workdir }); return Effect.gen(function* () { + yield* makeProjectDirectory(workdir, ".temp/project-ref"); yield* legacyServices({}).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("failed to load project ref: "); expect(out.stdoutText).toContain("supabase/postgres"); - }).pipe(Effect.ensuring(Effect.sync(() => rmSync(workdir, { recursive: true, force: true })))); + }); }); it.live("flushes telemetry state after the command finishes", () => { diff --git a/apps/cli/src/legacy/commands/services/services.layers.ts b/apps/cli/src/legacy/commands/services/services.layers.ts index 8217dbd4b8..44902259cb 100644 --- a/apps/cli/src/legacy/commands/services/services.layers.ts +++ b/apps/cli/src/legacy/commands/services/services.layers.ts @@ -1,24 +1,14 @@ import { FetchHttpClient } from "effect/unstable/http"; import { Layer } from "effect"; -import type * as HttpClient from "effect/unstable/http/HttpClient"; import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; -import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; -import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; -import { LegacyDebugLogger } from "../../shared/legacy-debug-logger.service.ts"; -import { - LegacyIdentityStitch, - legacyIdentityStitchLayer, -} from "../../shared/legacy-identity-stitch.ts"; +import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; -import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; -import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; -import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; /** * `services` always prints the local service matrix and only performs linked @@ -60,18 +50,5 @@ export const legacyServicesRuntimeLayer = (() => { commandRuntimeLayer(["services"]), ).pipe(Layer.provide(FetchHttpClient.layer)); - const _serviceCoverageCheck: Layer.Layer<LegacyServicesServices, unknown, unknown> = built; - void _serviceCoverageCheck; - return built; })(); - -type LegacyServicesServices = - | HttpClient.HttpClient - | LegacyCredentials - | LegacyCliConfig - | LegacyDebugLogger - | LegacyLinkedProjectCache - | LegacyTelemetryState - | LegacyIdentityStitch - | CommandRuntime; diff --git a/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts b/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts index 6c294535b9..071b11e163 100644 --- a/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts +++ b/apps/cli/src/legacy/commands/services/services.layers.unit.test.ts @@ -88,7 +88,7 @@ describe("legacyServicesRuntimeLayer — LegacyIdentityStitch exposure", () => { return Effect.gen(function* () { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); - }).pipe(Effect.provide(legacyServicesRuntimeLayer), Effect.provide(ambientStubs())); + }).pipe(Effect.provide(legacyServicesRuntimeLayer.pipe(Layer.provideMerge(ambientStubs())))); }, ); }); diff --git a/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts index c6bdb0206f..e633fe3492 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetASnippetOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -139,7 +139,7 @@ describe("legacy snippets download integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsInvalidIdError"); // `uuid.Parse` returns `invalid UUID length: 10` for "not-a-uuid" // (length 10), wrapped as `invalid snippet ID: %w`. @@ -161,7 +161,7 @@ describe("legacy snippets download integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("invalid snippet ID: invalid UUID length: 42"); } }).pipe(Effect.provide(layer)); @@ -175,7 +175,7 @@ describe("legacy snippets download integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("invalid snippet ID: invalid UUID format"); // The offending value must NOT be embedded in the error message. expect(dump).not.toContain(WRONG_FORMAT_ID); @@ -225,7 +225,7 @@ describe("legacy snippets download integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsDownloadUnexpectedStatusError"); expect(dump).toContain("unexpected download snippet status 503"); } @@ -240,7 +240,7 @@ describe("legacy snippets download integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsDownloadNetworkError"); expect(dump).toContain("failed to download snippet"); } diff --git a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts index 70a6c386c9..fe472fe882 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.integration.test.ts @@ -1,6 +1,6 @@ import { type V1ListAllSnippetsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Formatter } from "effect"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -196,7 +196,7 @@ describe("legacy snippets list integration", () => { const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsTomlEncodeError"); expect(dump).toContain( "failed to output toml: toml: cannot encode a map with non-string key type", @@ -247,7 +247,7 @@ describe("legacy snippets list integration", () => { const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsEnvNotSupportedError"); expect(dump).toContain("--output env flag is not supported"); } @@ -303,7 +303,7 @@ describe("legacy snippets list integration", () => { const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsListUnexpectedStatusError"); expect(dump).toContain("unexpected list snippets status 503"); } @@ -316,7 +316,7 @@ describe("legacy snippets list integration", () => { const exit = yield* Effect.exit(legacySnippetsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySnippetsListNetworkError"); expect(dump).toContain("failed to list snippets"); } diff --git a/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts b/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts index 587b8371f6..2849749084 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.e2e.test.ts @@ -15,13 +15,13 @@ describe("supabase snippets (legacy)", () => { test( "download with invalid UUID exits 1 with Go-format message", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["snippets", "download", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("invalid snippet ID"); - }, + () => + runSupabase(["snippets", "download", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("invalid snippet ID"); + }), ); }); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts index 0719f61221..2d607691e4 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/get/get.integration.test.ts @@ -1,10 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput, mockTty } from "../../../../../tests/helpers/mocks.ts"; @@ -202,34 +199,36 @@ describe("legacy ssl-enforcement get integration", () => { // This test owns its own workdir because it writes a project-ref file // before the layer is constructed (the resolver reads from // <workdir>/supabase/.temp/project-ref on layer-effect resolution). - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-fileref-")); const fileRef = "filerefabcdefghijklm"; - mkdirSync(join(localTempRoot, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(localTempRoot, "supabase", ".temp", "project-ref"), fileRef); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tempRoot.current, "supabase", ".temp"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(tempRoot.current, "supabase", ".temp", "project-ref"), + fileRef, + ); yield* legacySslEnforcementGet({ projectRef: Option.none() }); expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer))); }); it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-no-ref-")); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); @@ -240,11 +239,9 @@ describe("legacy ssl-enforcement get integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } - }).pipe( - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }); }); it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { @@ -255,7 +252,7 @@ describe("legacy ssl-enforcement get integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -266,7 +263,7 @@ describe("legacy ssl-enforcement get integration", () => { const exit = yield* Effect.exit(legacySslEnforcementGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacySslEnforcementGetUnexpectedStatusError"); expect(errorJson).toContain("unexpected SSL enforcement status 503"); } @@ -279,7 +276,7 @@ describe("legacy ssl-enforcement get integration", () => { const exit = yield* Effect.exit(legacySslEnforcementGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacySslEnforcementGetNetworkError"); expect(errorJson).toContain("failed to retrieve SSL enforcement config"); } @@ -317,13 +314,12 @@ describe("legacy ssl-enforcement get integration", () => { it.live("flushes telemetry even when ref resolution fails (no cache write)", () => { // Pre-PersistentPostRun-fix regression guard: telemetry must flush whether or not the // resolver succeeds. The linked-project cache only writes after a ref is resolved. - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-postrun-")); const telemetry = mockLegacyTelemetryStateTracked(); const cache = mockLegacyLinkedProjectCacheTracked(); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ @@ -340,8 +336,6 @@ describe("legacy ssl-enforcement get integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(false); - }).pipe( - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }); }); }); diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts index b06f5f0dc2..acc00bf721 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Layer, Formatter } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; @@ -76,7 +76,7 @@ describe("legacy ssl-enforcement experimental gate (Go PersistentPreRunE parity) ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyExperimentalRequiredError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -91,7 +91,7 @@ describe("legacy ssl-enforcement experimental gate (Go PersistentPreRunE parity) ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = JSON.stringify(exit.cause); + const causeText = Formatter.formatJson(exit.cause); expect(causeText).not.toContain("LegacyExperimentalRequiredError"); expect(causeText).toContain("LegacyPlatformAuthRequiredError"); } diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts index c2fb5ab867..f87198bc60 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/update/update.integration.test.ts @@ -1,10 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path, Formatter } from "effect"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -96,7 +93,7 @@ describe("legacy ssl-enforcement update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacySslEnforcementNoEnableDisableFlagError"); expect(errorJson).toContain("enable/disable not specified"); } @@ -118,7 +115,7 @@ describe("legacy ssl-enforcement update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacySslEnforcementMutuallyExclusiveFlagsError"); expect(errorJson).toContain( "if any flags in the group [enable-db-ssl-enforcement disable-db-ssl-enforcement] are set", @@ -377,38 +374,40 @@ describe("legacy ssl-enforcement update integration", () => { }); it.live("reads supabase/.temp/project-ref when env and flag are unset", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-update-int-fileref-")); const fileRef = "filerefabcdefghijklm"; - mkdirSync(join(localTempRoot, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(localTempRoot, "supabase", ".temp", "project-ref"), fileRef); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(tempRoot.current, "supabase", ".temp"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(tempRoot.current, "supabase", ".temp", "project-ref"), + fileRef, + ); yield* legacySslEnforcementUpdate({ projectRef: Option.none(), enableDbSslEnforcement: true, disableDbSslEnforcement: false, }); expect(api.requests[0]?.url).toContain(`/v1/projects/${fileRef}/`); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer))); }); it.live("fails with LegacyProjectNotLinkedError when no ref source matches off-TTY", () => { - const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-update-int-no-ref-")); const out = mockOutput({ format: "text" }); const api = mockLegacyPlatformApi({ response: { status: 200, body: SSL_ENFORCED } }); const cliConfig = mockLegacyCliConfig({ - workdir: localTempRoot, + workdir: tempRoot.current, projectId: Option.none(), }); const layer = buildLegacyTestRuntime({ out, api, cliConfig }); @@ -423,11 +422,9 @@ describe("legacy ssl-enforcement update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyProjectNotLinkedError"); } - }).pipe( - Effect.ensuring(Effect.sync(() => rmSync(localTempRoot, { recursive: true, force: true }))), - ); + }); }); it.live("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { @@ -442,7 +439,7 @@ describe("legacy ssl-enforcement update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -461,7 +458,7 @@ describe("legacy ssl-enforcement update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacySslEnforcementUpdateUnexpectedStatusError"); expect(errorJson).toContain("unexpected update SSL status 503"); } @@ -480,7 +477,7 @@ describe("legacy ssl-enforcement update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacySslEnforcementUpdateNetworkError"); expect(errorJson).toContain("failed to update ssl enforcement"); } diff --git a/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts index 74daa0039f..c4d73e2bee 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.command.unit.test.ts @@ -1,102 +1,94 @@ import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; -import { describe, expect, test } from "vitest"; import { normalizeCause } from "../../../../shared/output/normalize-error.ts"; import { legacySsoAddDomainsFlag } from "./add.command.ts"; describe("legacy sso add --domains flag (pflag StringSlice parity)", () => { - test("splits a comma-separated value into multiple domains", async () => { - const [, domains] = await Effect.runPromise( - legacySsoAddDomainsFlag + it.effect("splits a comma-separated value into multiple domains", () => + Effect.gen(function* () { + const [, domains] = yield* legacySsoAddDomainsFlag .parse({ flags: { domains: ["example.com,example.org"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual(["example.com", "example.org"]); + }), + ); - expect(domains).toEqual(["example.com", "example.org"]); - }); - - test("accumulates repeated occurrences, each CSV-split", async () => { - const [, domains] = await Effect.runPromise( - legacySsoAddDomainsFlag + it.effect("accumulates repeated occurrences, each CSV-split", () => + Effect.gen(function* () { + const [, domains] = yield* legacySsoAddDomainsFlag .parse({ flags: { domains: ["example.com,example.org", "example.net"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(domains).toEqual(["example.com", "example.org", "example.net"]); - }); + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual(["example.com", "example.org", "example.net"]); + }), + ); - test("defaults to an empty array when unset", async () => { - const [, domains] = await Effect.runPromise( - legacySsoAddDomainsFlag + it.effect("defaults to an empty array when unset", () => + Effect.gen(function* () { + const [, domains] = yield* legacySsoAddDomainsFlag .parse({ flags: {}, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(domains).toEqual([]); - }); + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual([]); + }), + ); - test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Go-verified (CLI-2005): `sso add --domains $'a.com\nb"c'` raises no - // parse error — pflag calls `csv.Reader.Read()` once, so the malformed - // second line is silently dropped. - const [, domains] = await Effect.runPromise( - legacySsoAddDomainsFlag + it.effect("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `sso add --domains $'a.com\nb"c'` raises no + // parse error — pflag calls `csv.Reader.Read()` once, so the malformed + // second line is silently dropped. + const [, domains] = yield* legacySsoAddDomainsFlag .parse({ flags: { domains: ['a.com\nb"c'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual(["a.com"]); + }), + ); - expect(domains).toEqual(["a.com"]); - }); - - test("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacySsoAddDomainsFlag + it.effect("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", () => + Effect.gen(function* () { + const exit = yield* legacySsoAddDomainsFlag .parse({ flags: { domains: ['"example.com'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\"example.com" for "--domains" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\"example.com" for "--domains" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field', + ); + } + }), + ); - test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `sso add --domains $'\n'` → - // `invalid argument "\n" for "--domains" flag: EOF`. - const exit = await Effect.runPromise( - legacySsoAddDomainsFlag + it.effect("rejects a blank-only value with pflag's EOF diagnostic", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `sso add --domains $'\n'` → + // `invalid argument "\n" for "--domains" flag: EOF`. + const exit = yield* legacySsoAddDomainsFlag .parse({ flags: { domains: ["\n"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\n" for "--domains" flag: EOF', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\n" for "--domains" flag: EOF', + ); + } + }), + ); }); diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index 35a61b8b74..2f1bce74ed 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -167,9 +167,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy // argv (the flag parses as unset), hence the emulation. Keep this ahead // of the profile/workdir/required-flag/mutex checks. if (scan.missingValueError !== undefined) { - return yield* Effect.fail( - new LegacySsoFlagNeedsArgumentError({ message: scan.missingValueError }), - ); + return yield* new LegacySsoFlagNeedsArgumentError({ message: scan.missingValueError }); } // The effective `--profile`/`SUPABASE_PROFILE` is resolved immediately @@ -203,9 +201,9 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy : undefined; const reconciledTokenForAux = reconciledTokenCached === undefined - ? Effect.succeed<Option.Option<Redacted.Redacted<string>> | undefined>(undefined) - : Effect.catch(reconciledTokenCached, () => - Effect.succeed(Option.none<Redacted.Redacted<string>>()), + ? Effect.succeed(Option.none<Redacted.Redacted<string>>()) + : Effect.orElseSucceed(reconciledTokenCached, () => + Option.none<Redacted.Redacted<string>>(), ); // The effective `--workdir`/`SUPABASE_WORKDIR` is validated after flag @@ -229,18 +227,16 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy // here. A genuine `-t saml` records a `type` occurrence via the scan's // shorthand map and never trips this. if (!occurrences.has("type") && scan.consumedFlagNames.has("type")) { - return yield* Effect.fail( - new LegacySsoAddRequiredFlagError({ message: `required flag(s) "type" not set` }), - ); + return yield* new LegacySsoAddRequiredFlagError({ + message: `required flag(s) "type" not set`, + }); } const changed = SSO_ADD_MUTEX_GROUP.filter((flagName) => occurrences.has(flagName)); if (changed.length > 1) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: cobraMutuallyExclusiveErrorMessage(SSO_ADD_MUTEX_GROUP, changed), - }), - ); + return yield* new LegacySsoMutexFlagError({ + message: cobraMutuallyExclusiveErrorMessage(SSO_ADD_MUTEX_GROUP, changed), + }); } // The scan and the Effect parser can disagree on more than the mutex: @@ -364,24 +360,21 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy statusCode: response.status, response, apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), + ...(yield* Effect.map(reconciledTokenForAux, (accessToken) => ({ accessToken }))), }); yield* creating?.fail() ?? Effect.void; if (response.status === 404) { - return yield* Effect.fail( - new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), - ); - } - return yield* Effect.fail( - new LegacySsoAddUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `Unexpected error adding identity provider: ${bodyText}`, + return yield* new LegacySsoAddSamlDisabledError({ + message: SAML_DISABLED_MESSAGE, upgradeSuggested, - }), - ); + }); + } + return yield* new LegacySsoAddUnexpectedStatusError({ + status: response.status, + body: bodyText, + message: `Unexpected error adding identity provider: ${bodyText}`, + upgradeSuggested, + }); } const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); diff --git a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts index 753d33fbd0..3f0a1af9e4 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.integration.test.ts @@ -1,8 +1,17 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Stdio } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Schema, + Stdio, +} from "effect"; +import * as Formatter from "effect/Formatter"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -31,6 +40,34 @@ const RESPONSE_PROVIDER = { }; const tempRoot = useLegacyTempWorkdir("supabase-sso-add-int-"); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const AttributeMappingSchema = Schema.Struct({ + keys: Schema.Record(Schema.String, Schema.Struct({ default: Schema.Finite })), +}); +const encodeAttributeMapping = Schema.encodeSync(Schema.fromJsonString(AttributeMappingSchema)); +const pendingWrites: Array<{ readonly path: string; readonly contents: string | Uint8Array }> = []; + +function writeFileSync(path: string, contents: string | Uint8Array) { + pendingWrites.push({ path, contents }); +} + +function flushFixtureWrites() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const write of pendingWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFile( + write.path, + typeof write.contents === "string" + ? new TextEncoder().encode(write.contents) + : write.contents, + ); + } + pendingWrites.length = 0; + }); +} interface SetupOpts { format?: "text" | "json" | "stream-json"; @@ -49,6 +86,7 @@ interface SetupOpts { * (usually via `cliArgsFor`), exactly as the real parser guarantees. */ cliArgs?: ReadonlyArray<string>; + env?: Readonly<Record<string, string>>; /** * The Effect-parsed `--profile` value (`LegacyProfileFlag`), which the real * parser sets for any `--profile` it accepted. Tests whose `cliArgs` carry a @@ -65,7 +103,7 @@ function jsonResponse( ) { return HttpClientResponse.fromWeb( request, - new Response(JSON.stringify(body), { + new Response(encodeJson(body), { status, headers: { "content-type": "application/json" }, }), @@ -150,6 +188,7 @@ function setup(opts: SetupOpts = {}) { const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); const layer = Layer.mergeAll( + Layer.effectDiscard(flushFixtureWrites().pipe(Effect.provide(BunServices.layer))), buildLegacyTestRuntime({ out, api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, @@ -157,6 +196,7 @@ function setup(opts: SetupOpts = {}) { telemetry: telemetry.layer, linkedProjectCache: cache.layer, analytics, + env: opts.env, goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), }), Stdio.layerTest({ @@ -256,7 +296,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoMutexFlagError"); // Established mutual-exclusion error template: group in // declaration order, changed flags sorted alphabetically. @@ -296,7 +336,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoMutexFlagError"); expect(dump).toContain( "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", @@ -325,7 +365,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAddMetadataFileError"); expect(dump).toContain("failed to open metadata file"); } @@ -370,7 +410,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyInvalidProjectRefError"); expect(dump).toContain("Invalid project ref format. Must be like"); } @@ -396,7 +436,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAddRequiredFlagError"); expect(dump).toContain('required flag(s) \\"type\\" not set'); } @@ -433,7 +473,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAddRequiredFlagError"); expect(dump).not.toContain("LegacySsoMutexFlagError"); } @@ -474,7 +514,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyPflagWorkdirError"); expect(dump).toContain( "failed to change workdir: chdir --metadata-file: no such file or directory", @@ -513,7 +553,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyPflagWorkdirError"); expect(dump).toContain( "failed to change workdir: chdir /nonexistent-sso-add-workdir: no such file or directory", @@ -590,7 +630,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAddRequiredFlagError"); expect(dump).toContain('required flag(s) \\"type\\" not set'); } @@ -615,7 +655,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidFlagValueError"); expect(dump).toContain( 'invalid argument \\"bogus\\" for \\"-t, --type\\" flag: must be one of [ saml ]', @@ -657,7 +697,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidFlagValueError"); expect(dump).toContain( 'invalid argument \\"\\" for \\"--skip-url-validation\\" flag: strconv.ParseBool: parsing \\"\\": invalid syntax', @@ -744,7 +784,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoFlagNeedsArgumentError"); expect(dump).toContain("flag needs an argument: --domains"); } @@ -803,7 +843,7 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); // URL validation runs against the consumed token, not the parsed // Option — it is not a valid HTTPS URL, so the command fails before // any request, like Go. URL implementations do not consistently @@ -853,7 +893,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoAddMetadataFileError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoAddMetadataFileError"); } }).pipe(Effect.provide(layer)); }); @@ -914,7 +954,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("only HTTPS Metadata URLs are supported"); expect(dump).toContain("Use --skip-url-validation to suppress this error"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ @@ -928,7 +968,7 @@ describe("legacy sso add integration", () => { it.live("reads attribute mapping JSON and preserves user-defined `default` field", () => { const path = join(tempRoot.current, "mapping.json"); - writeFileSync(path, JSON.stringify({ keys: { a: { default: 3 } } })); + writeFileSync(path, encodeAttributeMapping({ keys: { a: { default: 3 } } })); const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); return Effect.gen(function* () { @@ -989,7 +1029,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoAddSamlDisabledError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoAddSamlDisabledError"); } }).pipe(Effect.provide(layer)); }); @@ -1008,7 +1048,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAddUnexpectedStatusError"); expect(dump).toContain("Unexpected error adding identity provider"); } @@ -1042,7 +1082,7 @@ describe("legacy sso add integration", () => { it.live("preserves attribute_mapping `default` field in POST body", () => { const path = join(tempRoot.current, "mapping.json"); - writeFileSync(path, JSON.stringify({ keys: { a: { default: 42 } } })); + writeFileSync(path, encodeAttributeMapping({ keys: { a: { default: 42 } } })); const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); return Effect.gen(function* () { @@ -1068,7 +1108,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAddMetadataFileError"); // Error tail is `… Use --skip-url-validation to suppress this error` // (no trailing period). @@ -1093,7 +1133,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoAddMetadataFileError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoAddMetadataFileError"); } }).pipe(Effect.provide(layer)); }); @@ -1122,7 +1162,7 @@ describe("legacy sso add integration", () => { const exit = yield* Effect.exit(legacySsoAdd(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoAddAttributeMappingFileError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoAddAttributeMappingFileError"); } }).pipe(Effect.provide(layer)); }); @@ -1148,22 +1188,6 @@ describe("legacy sso add integration", () => { return path; }; - const withProfileEnv = (value: string | undefined) => { - const previous = process.env["SUPABASE_PROFILE"]; - if (value === undefined) { - delete process.env["SUPABASE_PROFILE"]; - } else { - process.env["SUPABASE_PROFILE"] = value; - } - return Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_PROFILE"]; - } else { - process.env["SUPABASE_PROFILE"] = previous; - } - }); - }; - it.live( "profile emulation: --domains consuming --profile POSTs to the env profile's host, not the parsed file's", () => { @@ -1176,10 +1200,10 @@ describe("legacy sso add integration", () => { // profile's api_url; the parsed file's host receives nothing. const envProfile = writeProfileYaml("env-profile.yml", "http://reconciled.example"); const alternate = writeProfileYaml("alternate.yml", "http://alternate.example"); - const restoreEnv = withProfileEnv(envProfile); const { layer, api, cache } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--domains", "--profile", alternate], profileFlag: alternate, + env: { SUPABASE_PROFILE: envProfile }, }); return Effect.gen(function* () { yield* legacySsoAdd(defaultFlags); @@ -1194,7 +1218,7 @@ describe("legacy sso add integration", () => { // The linked-project cache fill targets the reconciled host too — // it uses the process-wide profile. expect(cache.cachedApiUrl).toBe("http://reconciled.example"); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1205,7 +1229,6 @@ describe("legacy sso add integration", () => { // `"--metadata-url"` as the profile value; viper's extension gate // rejects it before any request (binary-verified: `failed to read // profile: Unsupported Config Type ""`). - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: [ "sso", @@ -1226,12 +1249,12 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyProfileLoadError"); expect(dump).toContain(`failed to read profile: Unsupported Config Type \\"\\"`); } expect(api.requests.length).toBe(0); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1241,7 +1264,6 @@ describe("legacy sso add integration", () => { // on b.yml — binary-verified: Go POSTs to b.yml's api_url. const first = writeProfileYaml("first.yml", "http://first.example"); const second = writeProfileYaml("second.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--profile", first, "--profile", second], profileFlag: first, @@ -1253,7 +1275,7 @@ describe("legacy sso add integration", () => { expect(posts[0]?.url).toBe( `http://second.example/v1/projects/${LEGACY_VALID_REF}/config/auth/sso/providers`, ); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }); it.live( @@ -1262,7 +1284,6 @@ describe("legacy sso add integration", () => { // Go loads the profile BEFORE ChangeWorkDir (`cmd/root.go:98-105` — // "Load profile before changing workdir"), and both run before // `ValidateRequiredFlags` and `ValidateFlagGroups`. - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: [ "sso", @@ -1286,14 +1307,14 @@ describe("legacy sso add integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyProfileLoadError"); expect(dump).not.toContain("LegacyPflagWorkdirError"); expect(dump).not.toContain("LegacySsoAddRequiredFlagError"); expect(dump).not.toContain("LegacySsoMutexFlagError"); } expect(api.requests.length).toBe(0); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1305,7 +1326,6 @@ describe("legacy sso add integration", () => { // targets `LegacyCliConfig.apiUrl` — the layer already loaded exactly // the profile Go would. const agreed = writeProfileYaml("agreed.yml", "http://agreed.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--profile", agreed], profileFlag: agreed, @@ -1319,7 +1339,7 @@ describe("legacy sso add integration", () => { expect(posts[0]?.url).toBe( `${LEGACY_DEFAULT_API_URL}/v1/projects/${LEGACY_VALID_REF}/config/auth/sso/providers`, ); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index b6f100b53f..f27a79a5ab 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -48,20 +48,19 @@ const handleListError = (ref: string, cause: SupabaseApiError) => response: legacyGateResponse(cause), }); if (mapped.status === 404) { - return yield* Effect.fail( - new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), - ); - } - return yield* Effect.fail( - new LegacySsoListUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, + return yield* new LegacySsoListSamlDisabledError({ + message: SAML_DISABLED_MESSAGE, upgradeSuggested, - }), - ); + }); + } + return yield* new LegacySsoListUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }); export const legacySsoList = Effect.fn("legacy.sso.list")(function* (flags: LegacySsoListFlags) { diff --git a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts index 2d847d7d64..905f05c3a8 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Option, Schema } from "effect"; +import * as Formatter from "effect/Formatter"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -41,6 +42,24 @@ const PROVIDER_ITEM = { const tempRoot = useLegacyTempWorkdir("supabase-sso-list-int-"); +const decodeListOutput = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + providers: Schema.Array( + Schema.Struct({ + domains: Schema.Array( + Schema.Struct({ + domain: Schema.String, + created_at: Schema.String, + updated_at: Schema.String, + }), + ), + }), + ), + }), + ), +); + interface SetupOpts { format?: "text" | "json" | "stream-json"; goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -171,9 +190,7 @@ describe("legacy sso list integration", () => { const { layer, out } = setup({ goOutput: "json", body: { items: [item] } }); return Effect.gen(function* () { yield* legacySsoList({ projectRef: Option.none() }); - const emitted = JSON.parse(out.stdoutText) as { - providers: Array<{ domains: Array<{ created_at: string; updated_at: string }> }>; - }; + const emitted = decodeListOutput(out.stdoutText); expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc"); expect(out.stdoutText).not.toContain("8682fcf4-4056-455c-bd93-f33295604929"); expect(out.stdoutText).not.toContain("9484591c-a203-4500-bea7-d0aaa845e2f5"); @@ -242,7 +259,7 @@ describe("legacy sso list integration", () => { const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoTomlEncodeError"); expect(dump).toContain("failed to output toml: toml: cannot encode array with nil element"); } @@ -272,7 +289,7 @@ describe("legacy sso list integration", () => { const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoListNetworkError"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ error_kind: "external_service", @@ -298,7 +315,7 @@ describe("legacy sso list integration", () => { const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoListSamlDisabledError"); expect(dump).toContain("Looks like SAML 2.0 support is not enabled"); } @@ -327,7 +344,7 @@ describe("legacy sso list integration", () => { const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoListUnexpectedStatusError"); expect(dump).toContain("unexpected error listing identity providers"); } @@ -340,7 +357,7 @@ describe("legacy sso list integration", () => { const exit = yield* Effect.exit(legacySsoList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoListNetworkError"); expect(dump).toContain("failed to list sso providers"); } diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index eff058991e..fe75a2b47a 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -24,7 +24,7 @@ import { LegacySsoRemoveUnexpectedStatusError, LegacySsoTomlEncodeError, } from "../sso.errors.ts"; -import { renderSingleProvider, validateUuid } from "../sso.format.ts"; +import { legacyQuoteSsoValue, renderSingleProvider, validateUuid } from "../sso.format.ts"; import type { LegacySsoRemoveFlags } from "./remove.command.ts"; const mapStatusOrNetwork = mapLegacyHttpError({ @@ -45,23 +45,19 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr response: legacyGateResponse(cause), }); if (mapped.status === 404) { - return yield* Effect.fail( - new LegacySsoRemoveNotFoundError({ - message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, - upgradeSuggested, - }), - ); - } - return yield* Effect.fail( - new LegacySsoRemoveUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, + return yield* new LegacySsoRemoveNotFoundError({ + message: `An identity provider with ID ${legacyQuoteSsoValue(providerId)} could not be found.`, upgradeSuggested, - }), - ); + }); + } + return yield* new LegacySsoRemoveUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }); export const legacySsoRemove = Effect.fn("legacy.sso.remove")(function* ( diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts index 4d60f0dac3..6b2218bb4d 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; +import * as Formatter from "effect/Formatter"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -130,7 +131,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoInvalidUuidError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoInvalidUuidError"); } }).pipe(Effect.provide(layer)); }); @@ -155,7 +156,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoRemoveNotFoundError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoRemoveNotFoundError"); } }).pipe(Effect.provide(layer)); }); @@ -168,7 +169,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoRemoveUnexpectedStatusError"); expect(dump).toContain("Unexpected error removing identity provider"); } @@ -219,7 +220,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoTomlEncodeError"); expect(dump).toContain("failed to output toml: toml: cannot encode array with nil element"); } @@ -252,7 +253,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoRemoveNetworkError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoRemoveNetworkError"); } }).pipe(Effect.provide(layer)); }); @@ -265,7 +266,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoRemoveUnexpectedStatusError"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ error_kind: "external_service", @@ -284,7 +285,7 @@ describe("legacy sso remove integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoRemoveNetworkError"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ error_kind: "external_service", diff --git a/apps/cli/src/legacy/commands/sso/show/show.handler.ts b/apps/cli/src/legacy/commands/sso/show/show.handler.ts index 51a1bf2333..ee339f62fe 100644 --- a/apps/cli/src/legacy/commands/sso/show/show.handler.ts +++ b/apps/cli/src/legacy/commands/sso/show/show.handler.ts @@ -21,7 +21,7 @@ import { LegacySsoShowUnexpectedStatusError, LegacySsoTomlEncodeError, } from "../sso.errors.ts"; -import { renderSingleProvider, validateUuid } from "../sso.format.ts"; +import { legacyQuoteSsoValue, renderSingleProvider, validateUuid } from "../sso.format.ts"; import type { LegacySsoShowFlags } from "./show.command.ts"; const mapStatusOrNetwork = mapLegacyHttpError({ @@ -37,13 +37,11 @@ const handleShowError = (providerId: string, cause: SupabaseApiError) => // `show` is intentionally omitted from the upgrade-suggestion paths // (see plan §"Telemetry parity"). if (mapped._tag === "LegacySsoShowUnexpectedStatusError" && mapped.status === 404) { - return yield* Effect.fail( - new LegacySsoShowNotFoundError({ - message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, - }), - ); + return yield* new LegacySsoShowNotFoundError({ + message: `An identity provider with ID ${legacyQuoteSsoValue(providerId)} could not be found.`, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }); export const legacySsoShow = Effect.fn("legacy.sso.show")(function* (flags: LegacySsoShowFlags) { @@ -80,11 +78,9 @@ export const legacySsoShow = Effect.fn("legacy.sso.show")(function* (flags: Lega if (goFmt === "env") { // Established `--output env` unsupported error message. - return yield* Effect.fail( - new LegacySsoShowEnvNotSupportedError({ - message: "--output env flag is not supported", - }), - ); + return yield* new LegacySsoShowEnvNotSupportedError({ + message: "--output env flag is not supported", + }); } if (goFmt === "json") { yield* output.raw(encodeGoJson(response)); diff --git a/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts b/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts index afb4b98bb7..e4888c2283 100644 --- a/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/show/show.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Option } from "effect"; +import * as Formatter from "effect/Formatter"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { @@ -76,7 +77,7 @@ describe("legacy sso show integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidUuidError"); expect(dump).toContain('identity provider ID \\"not-a-uuid\\" is not a UUID'); } @@ -109,7 +110,7 @@ describe("legacy sso show integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoShowNotFoundError"); expect(dump).toContain("An identity provider with ID"); expect(dump).toContain("could not be found"); @@ -129,7 +130,7 @@ describe("legacy sso show integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoShowUnexpectedStatusError"); expect(dump).toContain("Unexpected error fetching identity provider"); } @@ -148,7 +149,7 @@ describe("legacy sso show integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoShowNetworkError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoShowNetworkError"); } }).pipe(Effect.provide(layer)); }); @@ -165,7 +166,7 @@ describe("legacy sso show integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoShowEnvNotSupportedError"); expect(dump).toContain("--output env flag is not supported"); } diff --git a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts index dc23e57515..02dbfc629c 100644 --- a/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.e2e.test.ts @@ -9,65 +9,59 @@ describe("supabase sso (legacy)", () => { test( "info --output-format=json emits derived URLs (no auth needed)", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase( - ["sso", "info", "--project-ref", TEST_PROJECT_REF, "--output-format", "json"], - { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); + () => + runSupabase(["sso", "info", "--project-ref", TEST_PROJECT_REF, "--output-format", "json"], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout }) => { + expect(exitCode).toBe(0); + expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co/auth/v1/sso/saml/acs`); + expect(stdout).toContain( + `https://${TEST_PROJECT_REF}.supabase.co/auth/v1/sso/saml/metadata`, + ); + expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co`); + }), + ); + + test("info text mode prints all three URLs", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["sso", "info", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout }) => { expect(exitCode).toBe(0); expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co/auth/v1/sso/saml/acs`); expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co/auth/v1/sso/saml/metadata`); - expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co`); - }, + }), ); - test("info text mode prints all three URLs", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stdout } = await runSupabase( - ["sso", "info", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); - expect(exitCode).toBe(0); - expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co/auth/v1/sso/saml/acs`); - expect(stdout).toContain(`https://${TEST_PROJECT_REF}.supabase.co/auth/v1/sso/saml/metadata`); - }); - - test( - "show with invalid UUID exits 1 with Go-format message", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["sso", "show", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); + test("show with invalid UUID exits 1 with Go-format message", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["sso", "show", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout, stderr }) => { expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain(`identity provider ID "not-a-uuid" is not a UUID`); - }, + }), ); - test( - "remove with invalid UUID exits 1 with Go-format message", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["sso", "remove", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); + test("remove with invalid UUID exits 1 with Go-format message", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["sso", "remove", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout, stderr }) => { expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain(`identity provider ID "not-a-uuid" is not a UUID`); - }, + }), ); - test( - "update with invalid UUID exits 1 with Go-format message", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["sso", "update", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy", env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN } }, - ); + test("update with invalid UUID exits 1 with Go-format message", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["sso", "update", "not-a-uuid", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }).then(({ exitCode, stdout, stderr }) => { expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain(`identity provider ID "not-a-uuid" is not a UUID`); - }, + }), ); // `add`'s `--type` has no `Flag.optional` (see `add.command.ts`) — it is a @@ -84,35 +78,35 @@ describe("supabase sso (legacy)", () => { test( "add without --type: stdout stays clean, stderr is a single Go-parity line (no usage block)", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["sso", "add", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy" }, - ); - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - expect(stderr).toContain(`required flag(s) "type" not set`); - expect(stderr).not.toContain("USAGE"); - expect(stderr.trim().split("\n")).toHaveLength(2); - }, + () => + runSupabase(["sso", "add", "--project-ref", TEST_PROJECT_REF], { entrypoint: "legacy" }).then( + ({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain(`required flag(s) "type" not set`); + expect(stderr).not.toContain("USAGE"); + expect(stderr.trim().split("\n")).toHaveLength(2); + }, + ), ); test( "add with an invalid --type value: stdout stays clean, the usage content and the single error line land on stderr with no duplicate", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["sso", "add", "--type", "bogus", "--project-ref", TEST_PROJECT_REF], - { entrypoint: "legacy" }, - ); - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - expect(stderr).toContain("USAGE"); - const occurrences = stderr.split(`Invalid value for flag --type: "bogus"`).length - 1; - expect(occurrences).toBe(1); - expect( - stderr.trim().endsWith("Try rerunning the command with --debug to troubleshoot the error."), - ).toBe(true); - }, + () => + runSupabase(["sso", "add", "--type", "bogus", "--project-ref", TEST_PROJECT_REF], { + entrypoint: "legacy", + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain("USAGE"); + const occurrences = stderr.split(`Invalid value for flag --type: "bogus"`).length - 1; + expect(occurrences).toBe(1); + expect( + stderr + .trim() + .endsWith("Try rerunning the command with --debug to troubleshoot the error."), + ).toBe(true); + }), ); }); diff --git a/apps/cli/src/legacy/commands/sso/sso.format.ts b/apps/cli/src/legacy/commands/sso/sso.format.ts index a9330b2d4b..13a8478647 100644 --- a/apps/cli/src/legacy/commands/sso/sso.format.ts +++ b/apps/cli/src/legacy/commands/sso/sso.format.ts @@ -1,4 +1,4 @@ -import { Result } from "effect"; +import { DateTime, Option, Result, Schema } from "effect"; import { renderGlamourTable } from "../../output/legacy-glamour-table.ts"; import { LegacySsoInvalidUuidError } from "./sso.errors.ts"; @@ -96,19 +96,17 @@ export function validateUuid(input: string): Result.Result<string, LegacySsoInva ); } -const pad2 = (n: number): string => String(n).padStart(2, "0"); +export const legacyQuoteSsoValue = Schema.encodeSync(Schema.fromJsonString(Schema.String)); /** * RFC3339 → `YYYY-MM-DD HH:MM:SS` (UTC, no timezone label). */ export function formatSsoTimestamp(input?: string): string { if (input === undefined || input === null) return ""; - const date = new Date(input); - if (Number.isNaN(date.getTime())) return input; - return ( - `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + - `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` - ); + return Option.match(DateTime.make(input), { + onNone: () => input, + onSome: (date) => DateTime.formatIso(date).replace("T", " ").slice(0, 19), + }); } export function formatProtocol(saml: LegacySsoProviderView["saml"]): string { diff --git a/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts b/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts index 015ef86a3c..a348f52670 100644 --- a/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts +++ b/apps/cli/src/legacy/commands/sso/sso.metadata-url.ts @@ -55,11 +55,9 @@ export const validateMetadataUrl = ( // URL.protocol is already lowercased, so a direct compare against // "https:" is safe. if (parsed.protocol !== "https:") { - return yield* Effect.fail( - new LegacySsoMetadataUrlInvalidError({ - message: "only HTTPS Metadata URLs are supported", - }), - ); + return yield* new LegacySsoMetadataUrlInvalidError({ + message: "only HTTPS Metadata URLs are supported", + }); } const httpClient = yield* HttpClient.HttpClient; @@ -71,28 +69,26 @@ export const validateMetadataUrl = ( // Refuse redirects so the HTTPS-only guard above can't be sidestepped via 3xx → http://internal/. Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }), Effect.timeout(METADATA_URL_TIMEOUT), - Effect.catchTag("TimeoutError", () => - Effect.fail( - new LegacySsoMetadataUrlNetworkError({ - message: "failed to fetch metadata url: timeout", - }), - ), - ), - Effect.catchTag("HttpClientError", (cause) => - Effect.fail( - new LegacySsoMetadataUrlNetworkError({ - message: `failed to fetch metadata url: ${String(cause)}`, - }), - ), - ), + Effect.catchTags({ + TimeoutError: () => + Effect.fail( + new LegacySsoMetadataUrlNetworkError({ + message: "failed to fetch metadata url: timeout", + }), + ), + HttpClientError: (cause) => + Effect.fail( + new LegacySsoMetadataUrlNetworkError({ + message: `failed to fetch metadata url: ${String(cause)}`, + }), + ), + }), ); if (response.status !== 200) { - return yield* Effect.fail( - new LegacySsoMetadataUrlNetworkError({ - message: `unexpected metadata url status: ${response.status}`, - }), - ); + return yield* new LegacySsoMetadataUrlNetworkError({ + message: `unexpected metadata url status: ${response.status}`, + }); } const arrayBuffer = yield* response.arrayBuffer.pipe( @@ -105,11 +101,9 @@ export const validateMetadataUrl = ( ); if (arrayBuffer.byteLength > METADATA_URL_MAX_BYTES) { - return yield* Effect.fail( - new LegacySsoMetadataUrlNetworkError({ - message: `metadata url response exceeds maximum allowed size (${METADATA_URL_MAX_BYTES} bytes)`, - }), - ); + return yield* new LegacySsoMetadataUrlNetworkError({ + message: `metadata url response exceeds maximum allowed size (${METADATA_URL_MAX_BYTES} bytes)`, + }); } yield* validateMetadataXmlBytes( diff --git a/apps/cli/src/legacy/commands/sso/sso.saml.ts b/apps/cli/src/legacy/commands/sso/sso.saml.ts index 9f03d88aff..bd6f0b56cf 100644 --- a/apps/cli/src/legacy/commands/sso/sso.saml.ts +++ b/apps/cli/src/legacy/commands/sso/sso.saml.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem } from "effect"; +import { Effect, FileSystem, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; export type LegacySsoFileErrorReason = @@ -112,13 +112,15 @@ export const readAttributeMappingFile = }), ), ); - const parsed = yield* Effect.try({ - try: () => JSON.parse(content) as unknown, - catch: (cause) => + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + content, + ).pipe( + Effect.mapError((cause) => factory.openError({ message: `failed to parse attribute mapping: ${String(cause)}`, reason: "invalid_content", }), - }); + ), + ); return parsed; }); diff --git a/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts b/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts index 26e54d29e6..3a00ee6722 100644 --- a/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.saml.unit.test.ts @@ -1,9 +1,7 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Data, Effect, Exit, FileSystem, PlatformError } from "effect"; +import { Cause, Data, Effect, Exit, FileSystem, Option, Path, PlatformError, Schema } from "effect"; +import * as Formatter from "effect/Formatter"; import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { classifyCliErrorActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -46,24 +44,50 @@ function permissionDenied(method: "readFile" | "readFileString") { } const tempRoot = useLegacyTempWorkdir("sso-saml-unit-"); +const attributeMappingFixture = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))({ + keys: { a: { name: "xyz", default: 3 } }, +}); + +const writeTextFixture = (name: string, contents: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const file = path.join(tempRoot.current, name); + yield* fs.writeFileString(file, contents); + return file; + }); + +const writeBytesFixture = (name: string, contents: Uint8Array) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const file = path.join(tempRoot.current, name); + yield* fs.writeFile(file, contents); + return file; + }); + +const tempFixturePath = (name: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(tempRoot.current, name); + }); describe("readMetadataFile", () => { it.live("returns the file content on UTF-8 XML", () => { - const path = join(tempRoot.current, "good.xml"); - writeFileSync(path, '<?xml version="1.0"?><md/>'); return Effect.gen(function* () { + const path = yield* writeTextFixture("good.xml", '<?xml version="1.0"?><md/>'); const out = yield* readMetadata(path); expect(out).toBe('<?xml version="1.0"?><md/>'); }).pipe(Effect.provide(BunServices.layer)); }); it.live("fails with TestOpenError on missing file", () => { - const path = join(tempRoot.current, "missing.xml"); return Effect.gen(function* () { + const path = yield* tempFixturePath("missing.xml"); const exit = yield* Effect.exit(readMetadata(path)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("TestOpenError"); + expect(Formatter.formatJson(exit.cause)).toContain("TestOpenError"); } }).pipe(Effect.provide(BunServices.layer)); }); @@ -92,13 +116,12 @@ describe("readMetadataFile", () => { }); it.live("fails with TestNonUtf8Error on invalid UTF-8 bytes", () => { - const path = join(tempRoot.current, "bad.xml"); - writeFileSync(path, Buffer.from([0xff, 0xfe, 0xfd])); return Effect.gen(function* () { + const path = yield* writeBytesFixture("bad.xml", new Uint8Array([0xff, 0xfe, 0xfd])); const exit = yield* Effect.exit(readMetadata(path)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("TestNonUtf8Error"); expect(dump).toContain("is not UTF-8 encoded"); } @@ -108,9 +131,8 @@ describe("readMetadataFile", () => { describe("readAttributeMappingFile", () => { it.live("parses JSON and preserves user-defined keys (e.g. `default: 3`)", () => { - const path = join(tempRoot.current, "mapping.json"); - writeFileSync(path, JSON.stringify({ keys: { a: { name: "xyz", default: 3 } } })); return Effect.gen(function* () { + const path = yield* writeTextFixture("mapping.json", attributeMappingFixture); const parsed = yield* readAttrMapping(path); const root = parsed as { keys: { a: { default: number } } }; expect(root.keys.a.default).toBe(3); @@ -118,21 +140,20 @@ describe("readAttributeMappingFile", () => { }); it.live("fails with TestOpenError on malformed JSON", () => { - const path = join(tempRoot.current, "bad.json"); - writeFileSync(path, "{not json}"); return Effect.gen(function* () { + const path = yield* writeTextFixture("bad.json", "{not json}"); const exit = yield* Effect.exit(readAttrMapping(path)); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(BunServices.layer)); }); it.live("fails with TestOpenError on missing file", () => { - const path = join(tempRoot.current, "nonexistent.json"); return Effect.gen(function* () { + const path = yield* tempFixturePath("nonexistent.json"); const exit = yield* Effect.exit(readAttrMapping(path)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("TestOpenError"); expect(dump).toContain("failed to open attribute mapping"); } @@ -144,13 +165,20 @@ describe("readAttributeMappingFile", () => { openError: (args) => new LegacySsoUpdateAttributeMappingFileError(args), }); return Effect.gen(function* () { - const error = yield* read("/private/mapping.json").pipe(Effect.flip); - expect(classifyCliErrorActionability(error)).toMatchObject({ - error_kind: "user_actionable", - error_category: "permission", - suggestion_type: "none", - error_fingerprint: "tag:LegacySsoUpdateAttributeMappingFileError:filesystem", - }); + const exit = yield* Effect.exit(read("/private/mapping.json")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(classifyCliErrorActionability(failure.value)).toMatchObject({ + error_kind: "user_actionable", + error_category: "permission", + suggestion_type: "none", + error_fingerprint: "tag:LegacySsoUpdateAttributeMappingFileError:filesystem", + }); + } + } }).pipe( Effect.provide( FileSystem.layerNoop({ diff --git a/apps/cli/src/legacy/commands/sso/sso.string-slice-flags.integration.test.ts b/apps/cli/src/legacy/commands/sso/sso.string-slice-flags.integration.test.ts index 7769a053d7..d7d22f6a09 100644 --- a/apps/cli/src/legacy/commands/sso/sso.string-slice-flags.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/sso.string-slice-flags.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, Layer } from "effect"; +import * as Formatter from "effect/Formatter"; import { CliOutput, Command } from "effect/unstable/cli"; import { normalizeCause } from "../../../shared/output/normalize-error.ts"; @@ -115,7 +116,7 @@ describe("legacy sso StringSlice flags (pflag CSV parity)", () => { if (Exit.isFailure(exit)) { // Parse-time failure: the command's Management API layer (and its // eager token resolution) must never have been built. - expect(JSON.stringify(exit.cause)).not.toContain("LegacyPlatformAuthRequiredError"); + expect(Formatter.formatJson(exit.cause)).not.toContain("LegacyPlatformAuthRequiredError"); expect(normalizeCause(exit.cause).message).toBe(message); } expect(api.requests).toHaveLength(0); diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts index 64da28239c..7da6b1f360 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; -import { describe, expect, test } from "vitest"; import { normalizeCause } from "../../../../shared/output/normalize-error.ts"; import { legacySsoUpdateAddDomainsFlag, @@ -9,193 +9,177 @@ import { } from "./update.command.ts"; describe("legacy sso update domain flags (pflag StringSlice parity)", () => { - test("--domains splits a comma-separated value into multiple domains", async () => { - const [, domains] = await Effect.runPromise( - legacySsoUpdateDomainsFlag + it.effect("--domains splits a comma-separated value into multiple domains", () => + Effect.gen(function* () { + const [, domains] = yield* legacySsoUpdateDomainsFlag .parse({ flags: { domains: ["example.com,example.org"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(domains).toEqual(["example.com", "example.org"]); - }); - - test("--add-domains splits a comma-separated value into multiple domains", async () => { - const [, addDomains] = await Effect.runPromise( - legacySsoUpdateAddDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual(["example.com", "example.org"]); + }), + ); + + it.effect("--add-domains splits a comma-separated value into multiple domains", () => + Effect.gen(function* () { + const [, addDomains] = yield* legacySsoUpdateAddDomainsFlag .parse({ flags: { "add-domains": ["example.com,example.org"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(addDomains).toEqual(["example.com", "example.org"]); - }); - - test("--remove-domains splits a comma-separated value into multiple domains", async () => { - const [, removeDomains] = await Effect.runPromise( - legacySsoUpdateRemoveDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(addDomains).toEqual(["example.com", "example.org"]); + }), + ); + + it.effect("--remove-domains splits a comma-separated value into multiple domains", () => + Effect.gen(function* () { + const [, removeDomains] = yield* legacySsoUpdateRemoveDomainsFlag .parse({ flags: { "remove-domains": ["example.com,example.org"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(removeDomains).toEqual(["example.com", "example.org"]); - }); - - test("--domains defaults to an empty array when unset", async () => { - const [, domains] = await Effect.runPromise( - legacySsoUpdateDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(removeDomains).toEqual(["example.com", "example.org"]); + }), + ); + + it.effect("--domains defaults to an empty array when unset", () => + Effect.gen(function* () { + const [, domains] = yield* legacySsoUpdateDomainsFlag .parse({ flags: {}, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(domains).toEqual([]); - }); - - test("--add-domains defaults to an empty array when unset", async () => { - const [, addDomains] = await Effect.runPromise( - legacySsoUpdateAddDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual([]); + }), + ); + + it.effect("--add-domains defaults to an empty array when unset", () => + Effect.gen(function* () { + const [, addDomains] = yield* legacySsoUpdateAddDomainsFlag .parse({ flags: {}, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(addDomains).toEqual([]); - }); - - test("--remove-domains defaults to an empty array when unset", async () => { - const [, removeDomains] = await Effect.runPromise( - legacySsoUpdateRemoveDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(addDomains).toEqual([]); + }), + ); + + it.effect("--remove-domains defaults to an empty array when unset", () => + Effect.gen(function* () { + const [, removeDomains] = yield* legacySsoUpdateRemoveDomainsFlag .parse({ flags: {}, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(removeDomains).toEqual([]); - }); - - test("--domains= (explicit empty value) parses to an empty array, not a missing flag", async () => { - // The handler's `hasExplicitLongFlag` reads raw argv rather than this - // parsed value precisely because `--domains=` collapses to `[]` here, - // indistinguishable from the flag never being passed at all if you only - // looked at `.length`. - const [, domains] = await Effect.runPromise( - legacySsoUpdateDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(removeDomains).toEqual([]); + }), + ); + + it.effect("--domains= (explicit empty value) parses to an empty array, not a missing flag", () => + Effect.gen(function* () { + // The handler's `hasExplicitLongFlag` reads raw argv rather than this + // parsed value precisely because `--domains=` collapses to `[]` here, + // indistinguishable from the flag never being passed at all if you only + // looked at `.length`. + const [, domains] = yield* legacySsoUpdateDomainsFlag .parse({ flags: { domains: [""] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(domains).toEqual([]); - }); - - test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // `sso update <id> --domains $'a.com\nb"c'` raises no parse error — - // pflag calls `csv.Reader.Read()` once, so the malformed second line is - // silently dropped. - const [, domains] = await Effect.runPromise( - legacySsoUpdateDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual([]); + }), + ); + + it.effect("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => + Effect.gen(function* () { + // `sso update <id> --domains $'a.com\nb"c'` raises no parse error — + // pflag calls `csv.Reader.Read()` once, so the malformed second line is + // silently dropped. + const [, domains] = yield* legacySsoUpdateDomainsFlag .parse({ flags: { domains: ['a.com\nb"c'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(domains).toEqual(["a.com"]); - }); - - test("--domains rejects malformed CSV (bare quote) with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacySsoUpdateDomainsFlag + .pipe(Effect.provide(BunServices.layer)); + expect(domains).toEqual(["a.com"]); + }), + ); + + it.effect("--domains rejects malformed CSV (bare quote) with pflag's exact diagnostic", () => + Effect.gen(function* () { + const exit = yield* legacySsoUpdateDomainsFlag .parse({ flags: { domains: ['example"com'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "example\\"com" for "--domains" flag: parse error on line 1, column 8: bare " in non-quoted-field', - ); - } - }); - - test("--add-domains rejects malformed CSV with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacySsoUpdateAddDomainsFlag + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "example\\"com" for "--domains" flag: parse error on line 1, column 8: bare " in non-quoted-field', + ); + } + }), + ); + + it.effect("--add-domains rejects malformed CSV with pflag's exact diagnostic", () => + Effect.gen(function* () { + const exit = yield* legacySsoUpdateAddDomainsFlag .parse({ flags: { "add-domains": ['"x'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Go-verified (CLI-2005): `"x` is 2 bytes → EOF at column 3. - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\"x" for "--add-domains" flag: parse error on line 1, column 3: extraneous or missing " in quoted-field', - ); - } - }); - - test("--remove-domains rejects malformed CSV with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacySsoUpdateRemoveDomainsFlag + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // Go-verified (CLI-2005): `"x` is 2 bytes → EOF at column 3. + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\"x" for "--add-domains" flag: parse error on line 1, column 3: extraneous or missing " in quoted-field', + ); + } + }), + ); + + it.effect("--remove-domains rejects malformed CSV with pflag's exact diagnostic", () => + Effect.gen(function* () { + const exit = yield* legacySsoUpdateRemoveDomainsFlag .parse({ flags: { "remove-domains": ['"x'] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\"x" for "--remove-domains" flag: parse error on line 1, column 3: extraneous or missing " in quoted-field', - ); - } - }); - - test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `sso update <id> --add-domains $'\n\n'` → - // `invalid argument "\n\n" for "--add-domains" flag: EOF`. - const exit = await Effect.runPromise( - legacySsoUpdateAddDomainsFlag + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\"x" for "--remove-domains" flag: parse error on line 1, column 3: extraneous or missing " in quoted-field', + ); + } + }), + ); + + it.effect("rejects a blank-only value with pflag's EOF diagnostic", () => + Effect.gen(function* () { + // Go-verified (CLI-2005): `sso update <id> --add-domains $'\n\n'` → + // `invalid argument "\n\n" for "--add-domains" flag: EOF`. + const exit = yield* legacySsoUpdateAddDomainsFlag .parse({ flags: { "add-domains": ["\n\n"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\n\\n" for "--add-domains" flag: EOF', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\n\\n" for "--add-domains" flag: EOF', + ); + } + }), + ); }); diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index 3edf5fb7fa..9a16661e66 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -1,5 +1,5 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Redacted, Result, Stdio } from "effect"; +import { Effect, Option, Redacted, Result, Schema, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -52,7 +52,12 @@ import { LegacySsoAccessTokenError, LegacySsoTomlEncodeError, } from "../sso.errors.ts"; -import { renderSingleProvider, toLegacySsoProviderView, validateUuid } from "../sso.format.ts"; +import { + legacyQuoteSsoValue, + renderSingleProvider, + toLegacySsoProviderView, + validateUuid, +} from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; import { LEGACY_SSO_NAME_ID_FORMATS, @@ -129,23 +134,19 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError response: legacyGateResponse(cause), }); if (mapped.status === 404) { - return yield* Effect.fail( - new LegacySsoUpdateNotFoundError({ - message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, - upgradeSuggested, - }), - ); - } - return yield* Effect.fail( - new LegacySsoUpdateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, + return yield* new LegacySsoUpdateNotFoundError({ + message: `An identity provider with ID ${legacyQuoteSsoValue(providerId)} could not be found.`, upgradeSuggested, - }), - ); + }); + } + return yield* new LegacySsoUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }); interface ExistingDomainItem { @@ -270,9 +271,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // Effect parser accepts that argv (the flag parses as unset), so no // GET/PUT may happen here either. Keep this ahead of the arity check. if (scan.missingValueError !== undefined) { - return yield* Effect.fail( - new LegacySsoFlagNeedsArgumentError({ message: scan.missingValueError }), - ); + return yield* new LegacySsoFlagNeedsArgumentError({ message: scan.missingValueError }); } // Arity validation counts pflag-effective positionals, which shift away @@ -284,11 +283,9 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // so re-count from the scan (gated on `anchored`: an unscoped scan has // no positional information). if (scan.anchored && scan.positionals.length !== 1) { - return yield* Effect.fail( - new LegacySsoUpdateArityError({ - message: `accepts 1 arg(s), received ${scan.positionals.length}`, - }), - ); + return yield* new LegacySsoUpdateArityError({ + message: `accepts 1 arg(s), received ${scan.positionals.length}`, + }); } // The effective `--profile`/`SUPABASE_PROFILE` is resolved immediately @@ -318,9 +315,9 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( : undefined; const reconciledTokenForAux = reconciledTokenCached === undefined - ? Effect.succeed<Option.Option<Redacted.Redacted<string>> | undefined>(undefined) - : Effect.catch(reconciledTokenCached, () => - Effect.succeed(Option.none<Redacted.Redacted<string>>()), + ? Effect.succeed(Option.none<Redacted.Redacted<string>>()) + : Effect.orElseSucceed(reconciledTokenCached, () => + Option.none<Redacted.Redacted<string>>(), ); // The effective `--workdir`/`SUPABASE_WORKDIR` is validated after arity @@ -334,11 +331,9 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( for (const group of SSO_UPDATE_MUTEX_GROUPS) { const changed = group.filter((flagName) => occurrences.has(flagName)); if (changed.length > 1) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: cobraMutuallyExclusiveErrorMessage(group, changed), - }), - ); + return yield* new LegacySsoMutexFlagError({ + message: cobraMutuallyExclusiveErrorMessage(group, changed), + }); } } @@ -429,20 +424,19 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( const contentType = response.headers["content-type"] ?? ""; if (response.status === 200 && contentType.includes("json")) { // A 200 JSON body that fails to parse exits with the parse error - // before any PUT; detail text is `JSON.parse`'s (documented - // micro-divergence). - let parsed: unknown; - try { - parsed = JSON.parse(rawBody); - } catch (cause) { - yield* fetching?.fail() ?? Effect.void; - return yield* Effect.fail( - new LegacySsoUpdateNetworkError({ - message: `failed to get sso provider: ${cause instanceof Error ? cause.message : String(cause)}`, - decode: true, - }), - ); - } + // before any PUT; detail text is the decoder's documented message. + const parsed = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), + )(rawBody).pipe( + Effect.mapError( + (cause) => + new LegacySsoUpdateNetworkError({ + message: `failed to get sso provider: ${String(cause)}`, + decode: true, + }), + ), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); return { domains: extractDomainItems(parsed) }; } // Non-200 — or a 200 without a JSON content type, which falls into @@ -455,26 +449,20 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( statusCode: response.status, response, apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), + ...(yield* Effect.map(reconciledTokenForAux, (accessToken) => ({ accessToken }))), }); if (response.status === 404) { - return yield* Effect.fail( - new LegacySsoUpdateNotFoundError({ - message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, - upgradeSuggested, - }), - ); - } - return yield* Effect.fail( - new LegacySsoUpdateUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `unexpected error fetching identity provider: ${bodyText}`, + return yield* new LegacySsoUpdateNotFoundError({ + message: `An identity provider with ID ${legacyQuoteSsoValue(providerId)} could not be found.`, upgradeSuggested, - }), - ); + }); + } + return yield* new LegacySsoUpdateUnexpectedStatusError({ + status: response.status, + body: bodyText, + message: `unexpected error fetching identity provider: ${bodyText}`, + upgradeSuggested, + }); }); // Always GETs first, regardless of which flags are set. @@ -569,19 +557,17 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( statusCode: response.status, response, apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), + ...(yield* Effect.map(reconciledTokenForAux, (accessToken) => ({ accessToken }))), }); yield* fetching?.fail() ?? Effect.void; - return yield* Effect.fail( + return yield* ( // Reuses the GET error message even for PUT. new LegacySsoUpdateUnexpectedStatusError({ status: response.status, body: bodyText, message: `unexpected error fetching identity provider: ${bodyText}`, upgradeSuggested, - }), + }) ); } diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index 79e73ace86..3308c4a1c1 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -1,8 +1,19 @@ -import { writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option, Redacted, Stdio } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + ConfigProvider, + Redacted, + Schema, + Stdio, +} from "effect"; +import * as Formatter from "effect/Formatter"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; @@ -36,6 +47,16 @@ const RESPONSE_PROVIDER = { }; const tempRoot = useLegacyTempWorkdir("supabase-sso-update-int-"); +const testPath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); + +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function writeText(path: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }); +} interface SetupOpts { format?: "text" | "json" | "stream-json"; @@ -81,7 +102,7 @@ function jsonResponse( ) { return HttpClientResponse.fromWeb( request, - new Response(JSON.stringify(body), { + new Response(encodeJson(body), { status, headers: { "content-type": "application/json" }, }), @@ -190,6 +211,7 @@ function setup(opts: SetupOpts = {}) { Stdio.layerTest({ args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), }), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { SUPABASE_NO_KEYRING: "1" } })), stitchLayer, opts.profileFlag === undefined ? Layer.empty @@ -275,7 +297,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoInvalidUuidError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoInvalidUuidError"); } }).pipe(Effect.provide(layer)); }); @@ -295,7 +317,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoUpdateNotFoundError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoUpdateNotFoundError"); } }).pipe(Effect.provide(layer)); }); @@ -306,7 +328,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateUnexpectedStatusError"); expect(dump).toContain("unexpected error fetching identity provider"); } @@ -323,7 +345,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoMutexFlagError"); // Byte-matches cobra's `validateExclusiveFlagGroups` template // (`flag_groups.go:204`): group in registration order, changed flags @@ -353,7 +375,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoMutexFlagError"); expect(dump).toContain( "if any flags in the group [domains remove-domains] are set none of the others can be; [domains remove-domains] were all set", @@ -378,7 +400,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoMutexFlagError"); } }).pipe(Effect.provide(layer)); }, @@ -439,7 +461,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain( "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", ); @@ -467,7 +489,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoMutexFlagError"); expect(dump).not.toContain("LegacySsoInvalidUuidError"); } @@ -498,7 +520,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoMutexFlagError"); // Go registers this pair too (`cmd/sso.go:178`) — it was left emitting // a hand-written message alongside the domains groups' custom text @@ -532,7 +554,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateMetadataFileError"); expect(dump).toContain("failed to open metadata file"); } @@ -596,7 +618,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).toContain("accepts 1 arg(s), received 2"); } @@ -633,7 +655,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).toContain("accepts 1 arg(s), received 2"); } @@ -656,7 +678,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).toContain("accepts 1 arg(s), received 2"); } @@ -718,7 +740,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).not.toContain("LegacySsoMutexFlagError"); } @@ -752,7 +774,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyPflagWorkdirError"); expect(dump).toContain( "failed to change workdir: chdir --metadata-file: no such file or directory", @@ -789,7 +811,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyPflagWorkdirError"); expect(dump).toContain( "failed to change workdir: chdir /nonexistent-sso-update-workdir: no such file or directory", @@ -809,7 +831,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate({ ...defaultFlags, providerId: "a" })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).toContain("accepts 1 arg(s), received 2"); expect(dump).not.toContain("LegacyPflagWorkdirError"); @@ -834,7 +856,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).not.toContain("LegacySsoInvalidUuidError"); } @@ -877,7 +899,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoFlagNeedsArgumentError"); expect(dump).toContain("flag needs an argument: --domains"); } @@ -912,7 +934,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoFlagNeedsArgumentError"); expect(dump).toContain("flag needs an argument: --add-domains"); expect(dump).not.toContain("LegacySsoUpdateArityError"); @@ -951,7 +973,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).toContain("accepts 1 arg(s), received 2"); } @@ -1034,7 +1056,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateMetadataFileError"); expect(dump).toContain("only HTTPS Metadata URLs are supported"); } @@ -1109,7 +1131,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidFlagValueError"); expect(dump).toContain( 'invalid argument \\"yes\\" for \\"--skip-url-validation\\" flag: strconv.ParseBool: parsing \\"yes\\": invalid syntax', @@ -1150,7 +1172,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidFlagValueError"); expect(dump).toContain( 'invalid argument \\"\\" for \\"--skip-url-validation\\" flag: strconv.ParseBool: parsing \\"\\": invalid syntax', @@ -1187,7 +1209,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidFlagValueError"); expect(dump).toContain( 'invalid argument \\"bogus\\" for \\"--name-id-format\\" flag: must be one of [ urn:oasis', @@ -1214,7 +1236,7 @@ describe("legacy sso update integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoInvalidFlagValueError"); expect(dump).not.toContain("LegacySsoFlagNeedsArgumentError"); } @@ -1337,11 +1359,11 @@ describe("legacy sso update integration", () => { }); it.live("reads metadata file and sends as metadata_xml on PUT", () => { - const path = join(tempRoot.current, "good.xml"); - writeFileSync(path, '<?xml version="1.0"?><md/>'); + const path = testPath.join(tempRoot.current, "good.xml"); const flags = { ...defaultFlags, metadataFile: Option.some(path) }; const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); return Effect.gen(function* () { + yield* writeText(path, '<?xml version="1.0"?><md/>'); yield* legacySsoUpdate(flags); const putReq = api.requests.find((r) => r.method === "PUT"); expect((putReq?.body as { metadata_xml?: string })?.metadata_xml).toContain("<md/>"); @@ -1349,11 +1371,11 @@ describe("legacy sso update integration", () => { }); it.live("preserves attribute_mapping `default` field in PUT body", () => { - const path = join(tempRoot.current, "map.json"); - writeFileSync(path, JSON.stringify({ keys: { a: { default: 3 } } })); + const path = testPath.join(tempRoot.current, "map.json"); const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); return Effect.gen(function* () { + yield* writeText(path, encodeJson({ keys: { a: { default: 3 } } })); yield* legacySsoUpdate(flags); const putReq = api.requests.find((r) => r.method === "PUT"); const mapping = (putReq?.body as { attribute_mapping?: { keys: { a: { default: number } } } }) @@ -1381,7 +1403,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoUpdateUnexpectedStatusError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacySsoUpdateUnexpectedStatusError"); } expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); }).pipe(Effect.provide(layer)); @@ -1461,7 +1483,7 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateMetadataFileError"); // Error tail is `… Use --skip-url-validation to suppress this error.` // (trailing period). @@ -1476,15 +1498,17 @@ describe("legacy sso update integration", () => { }); it.live("malformed attribute-mapping JSON surfaces a tagged error", () => { - const path = join(tempRoot.current, "malformed.json"); - writeFileSync(path, "{not json}"); + const path = testPath.join(tempRoot.current, "malformed.json"); const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; const { layer } = setup({ cliArgs: cliArgsFor(flags) }); return Effect.gen(function* () { + yield* writeText(path, "{not json}"); const exit = yield* Effect.exit(legacySsoUpdate(flags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoUpdateAttributeMappingFileError"); + expect(Formatter.formatJson(exit.cause)).toContain( + "LegacySsoUpdateAttributeMappingFileError", + ); } }).pipe(Effect.provide(layer)); }); @@ -1508,42 +1532,21 @@ describe("legacy sso update integration", () => { // aborts the command when the profile cannot be loaded. // ------------------------------------------------------------------------- - const writeProfileYaml = (name: string, apiUrl: string): string => { - const path = join(tempRoot.current, name); - writeFileSync( - path, - [ - `name: ${name.replace(/\.[^.]*$/, "")}`, - `api_url: ${apiUrl}`, - `dashboard_url: ${apiUrl}/dashboard`, - "project_host: supabase.co", - ].join("\n"), - ); - return path; + const writeProfileYaml = (name: string, apiUrl: string) => { + const path = testPath.join(tempRoot.current, name); + const contents = [ + `name: ${name.replace(/\.[^.]*$/, "")}`, + `api_url: ${apiUrl}`, + `dashboard_url: ${apiUrl}/dashboard`, + "project_host: supabase.co", + ].join("\n"); + return { path, write: writeText(path, contents) }; }; - const withProfileEnv = (value: string | undefined) => { - const previous = process.env["SUPABASE_PROFILE"]; - const previousNoKeyring = process.env["SUPABASE_NO_KEYRING"]; - process.env["SUPABASE_NO_KEYRING"] = "1"; - if (value === undefined) { - delete process.env["SUPABASE_PROFILE"]; - } else { - process.env["SUPABASE_PROFILE"] = value; - } - return Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_PROFILE"]; - } else { - process.env["SUPABASE_PROFILE"] = previous; - } - if (previousNoKeyring === undefined) { - delete process.env["SUPABASE_NO_KEYRING"]; - } else { - process.env["SUPABASE_NO_KEYRING"] = previousNoKeyring; - } - }); - }; + const writeProfiles = ( + first: ReturnType<typeof writeProfileYaml>, + second: ReturnType<typeof writeProfileYaml>, + ) => Effect.all([first.write, second.write]); it.live( "profile emulation: repeated --profile resolves last-wins — GET and PUT both target the last file's host", @@ -1555,13 +1558,21 @@ describe("legacy sso update integration", () => { // first.yml's host receives nothing. const first = writeProfileYaml("first.yml", "http://first.example"); const second = writeProfileYaml("second.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const testSetup = setup({ - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--profile", + first.path, + "--profile", + second.path, + ], + profileFlag: first.path, }); const { layer, api, cache } = testSetup; return Effect.gen(function* () { + yield* writeProfiles(first, second); yield* legacySsoUpdate(defaultFlags); const providerUrl = `http://second.example/v1/projects/${LEGACY_VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`; const get = api.requests.find((r) => r.method === "GET"); @@ -1578,7 +1589,7 @@ describe("legacy sso update integration", () => { // The linked-project cache fill targets the reconciled host too — // it uses the process-wide profile. expect(cache.cachedApiUrl).toBe("http://second.example"); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1587,25 +1598,33 @@ describe("legacy sso update integration", () => { () => { const first = writeProfileYaml("first-404.yml", "http://first.example"); const second = writeProfileYaml("second-404.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ getStatus: 404, getBody: {}, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--profile", + first.path, + "--profile", + second.path, + ], + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateNotFoundError"); expect(dump).toContain( `An identity provider with ID \\"${VALID_PROVIDER_ID}\\" could not be found.`, ); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1614,23 +1633,31 @@ describe("legacy sso update integration", () => { () => { const first = writeProfileYaml("first-500.yml", "http://first.example"); const second = writeProfileYaml("second-500.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ getStatus: 500, getBody: { error: "boom" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--profile", + first.path, + "--profile", + second.path, + ], + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateUnexpectedStatusError"); expect(dump).toContain("unexpected error fetching identity provider:"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1642,7 +1669,6 @@ describe("legacy sso update integration", () => { // skipped, matching the typed client's schema behavior. const first = writeProfileYaml("first-merge.yml", "http://first.example"); const second = writeProfileYaml("second-merge.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api, cache } = setup({ getBody: { id: VALID_PROVIDER_ID, @@ -1655,13 +1681,14 @@ describe("legacy sso update integration", () => { "--add-domains", "new.com", "--profile", - first, + first.path, "--profile", - second, + second.path, ], - profileFlag: first, + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); yield* legacySsoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); const put = api.requests.find((r) => r.method === "PUT"); expect(put?.url).toBe( @@ -1675,7 +1702,7 @@ describe("legacy sso update integration", () => { // (review r3684524241). `undefined` would fall back to the config // layer's credentials service. expect(cache.cachedAccessToken).toBeDefined(); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1685,16 +1712,6 @@ describe("legacy sso update integration", () => { // substituted, and no request may be issued. const first = writeProfileYaml("first-notoken.yml", "http://first.example"); const second = writeProfileYaml("second-notoken.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); - const previousNoKeyring = process.env["SUPABASE_NO_KEYRING"]; - process.env["SUPABASE_NO_KEYRING"] = "1"; - const restoreNoKeyring = Effect.sync(() => { - if (previousNoKeyring === undefined) { - delete process.env["SUPABASE_NO_KEYRING"]; - } else { - process.env["SUPABASE_NO_KEYRING"] = previousNoKeyring; - } - }); const { layer, api } = setup({ accessToken: Option.none(), cliArgs: [ @@ -1704,24 +1721,25 @@ describe("legacy sso update integration", () => { "--add-domains", "new.com", "--profile", - first, + first.path, "--profile", - second, + second.path, ], - profileFlag: first, + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, addDomains: ["new.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoAccessTokenError"); expect(dump).toContain("Access token not provided. Supply an access token by running"); } expect(api.requests).toHaveLength(0); - }).pipe(Effect.ensuring(restoreNoKeyring), Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }); it.live("profile emulation: the missing-token gate fires AFTER the mutex check, like Go", () => { @@ -1730,7 +1748,6 @@ describe("legacy sso update integration", () => { // the reconciled profile has no token (validation-order parity). const first = writeProfileYaml("first-order.yml", "http://first.example"); const second = writeProfileYaml("second-order.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ accessToken: Option.none(), cliArgs: [ @@ -1742,30 +1759,30 @@ describe("legacy sso update integration", () => { "--add-domains", "new.com", "--profile", - first, + first.path, "--profile", - second, + second.path, ], - profileFlag: first, + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], addDomains: ["new.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("[add-domains domains] were all set"); expect(dump).not.toContain("Access token not provided"); } expect(api.requests).toHaveLength(0); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }); it.live("profile emulation: the reconciled GET tolerates a body without a domains array", () => { const first = writeProfileYaml("first-nodom.yml", "http://first.example"); const second = writeProfileYaml("second-nodom.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ getBody: { id: VALID_PROVIDER_ID }, cliArgs: [ @@ -1775,17 +1792,18 @@ describe("legacy sso update integration", () => { "--add-domains", "new.com", "--profile", - first, + first.path, "--profile", - second, + second.path, ], - profileFlag: first, + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); yield* legacySsoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); const put = api.requests.find((r) => r.method === "PUT"); expect((put?.body as { domains?: string[] })?.domains).toEqual(["new.com"]); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }); it.live( @@ -1794,7 +1812,6 @@ describe("legacy sso update integration", () => { // `sso update <id> --profile --add-domains`: pflag binds // `"--add-domains"` as the profile value (positional count stays 1); // viper's extension gate rejects it before any request. - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", "--add-domains"], }); @@ -1802,12 +1819,12 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacyProfileLoadError"); expect(dump).toContain(`failed to read profile: Unsupported Config Type \\"\\"`); } expect(api.requests.length).toBe(0); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1818,17 +1835,25 @@ describe("legacy sso update integration", () => { // `failed to get sso provider: %w` before any PUT. const first = writeProfileYaml("first-badjson.yml", "http://first.example"); const second = writeProfileYaml("second-badjson.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ getRaw: { status: 200, body: "{not json", contentType: "application/json" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--profile", + first.path, + "--profile", + second.path, + ], + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateNetworkError"); expect(dump).toContain("failed to get sso provider:"); const classified = classifyCliCauseActionability(exit.cause); @@ -1837,7 +1862,7 @@ describe("legacy sso update integration", () => { expect(classified.error_fingerprint).toBe("tag:LegacySsoUpdateNetworkError:api_response"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1848,22 +1873,30 @@ describe("legacy sso update integration", () => { // with the raw body — no PUT. const first = writeProfileYaml("first-nonjson.yml", "http://first.example"); const second = writeProfileYaml("second-nonjson.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ getRaw: { status: 200, body: "plain text body", contentType: "text/plain" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--profile", + first.path, + "--profile", + second.path, + ], + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateUnexpectedStatusError"); expect(dump).toContain("unexpected error fetching identity provider: plain text body"); } expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1875,15 +1908,23 @@ describe("legacy sso update integration", () => { // host as the main call. const first = writeProfileYaml("first-gate.yml", "http://first.example"); const second = writeProfileYaml("second-gate.yml", "http://second.example"); - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ getStatus: 403, getBody: {}, upgradeGate: "gated", - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--profile", + first.path, + "--profile", + second.path, + ], + profileFlag: first.path, }); return Effect.gen(function* () { + yield* writeProfiles(first, second); const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1899,7 +1940,7 @@ describe("legacy sso update integration", () => { expect(project?.url).toBe(`http://second.example/v1/projects/${LEGACY_VALID_REF}`); expect(entitlements?.url).toBe("http://second.example/v1/organizations/acme/entitlements"); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }, ); @@ -1908,7 +1949,6 @@ describe("legacy sso update integration", () => { // wrong arg count is reported even when the profile is also unloadable // (binary-verified for workdir in round 6; LoadProfile sits in the same // PersistentPreRunE, before ChangeWorkDir). - const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: ["sso", "update", "a", "b", "--profile", "--metadata-url", "u"], }); @@ -1916,11 +1956,11 @@ describe("legacy sso update integration", () => { const exit = yield* Effect.exit(legacySsoUpdate({ ...defaultFlags, providerId: "a" })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const dump = JSON.stringify(exit.cause); + const dump = Formatter.formatJson(exit.cause); expect(dump).toContain("LegacySsoUpdateArityError"); expect(dump).not.toContain("LegacyProfileLoadError"); } expect(api.requests.length).toBe(0); - }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); + }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md index 87d465136f..4af5e52658 100644 --- a/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/start/SIDE_EFFECTS.md @@ -171,7 +171,6 @@ not implemented. | `BITBUCKET_CLONE_DIR` | When non-empty, drops named volumes and `--security-opt` from every container create | no | | `DOCKER_HOST` / `DOCKER_CONTEXT` / `DOCKER_TLS_VERIFY` / `DOCKER_CERT_PATH` / `DOCKER_API_VERSION` / `DOCKER_CONFIG` | Read (ambient shell OR a project `.env`/`.env.<env>`/`.env.local` file) to discover the Docker daemon this whole command talks to; `DOCKER_HOST` is also re-derived and set on Vector's container env so it can reach the host's Docker socket for log collection | no | | `KONG_NGINX_WORKER_PROCESSES` | Read (ambient shell or project dotenv) into Kong's own container env (defaults to `"1"` when unset) | no | -| `HTTP_PROXY` / `http_proxy` / `HTTPS_PROXY` / `https_proxy` / `NO_PROXY` / `no_proxy` | Bun proxy settings. After project dotenv and container creation, `start` appends `localhost,127.0.0.1,[::1]` to the effective no-proxy value before local Kong probes and seeding; it never changes project/container env and ends with this CLI process. | no | `docker`/`podman` must be resolvable on `PATH` — same fallback behavior as `stop`/`status`. diff --git a/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts index b8af6e7f1f..e63ad64cfb 100644 --- a/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts +++ b/apps/cli/src/legacy/commands/start/lib/legacy-env-or-default.ts @@ -2,12 +2,11 @@ * `envOrDefault(key, def)`: env-var-if-set-else-default — an env var that is * SET but empty is used verbatim (unlike `legacy-local-config-values.ts`'s * `envOverride`, which treats an empty resolved value as unset). `projectEnvValues` - * mirrors that module's own merged (dotenv + ambient shell, ambient-wins) map; - * `??` only skips a `null`/`undefined` operand, never an empty string, so - * this naturally reproduces "set, even if empty" semantics without a separate - * presence check. No `SUPABASE_` prefix and no `env(VAR)` indirection — this - * reads the raw env var directly, bypassing the decode-hook chain those only - * apply to. + * is the merged (dotenv + ambient shell, ambient-wins) map supplied by the start command's + * Effect config boundary; `??` only skips a `null`/`undefined` operand, never an empty string, so + * this naturally reproduces "set, even if empty" semantics without a separate presence check. + * No `SUPABASE_` prefix and no `env(VAR)` indirection — this reads the resolved map directly, + * bypassing the decode-hook chain those only apply to. * * Hoisted here (`start/lib/`, the `start` command family's shared root) per * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Storage's @@ -20,5 +19,5 @@ export function legacyEnvOrDefault( def: string, projectEnvValues: Readonly<Record<string, string>> | undefined, ): string { - return projectEnvValues?.[key] ?? process.env[key] ?? def; + return projectEnvValues?.[key] ?? def; } diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts index 913582d235..124e73b3f4 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.integration.test.ts @@ -1,9 +1,8 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { edgeRuntimeNofileUlimit } from "@supabase/stack/effect"; -import { Deferred, Effect, Exit, Sink, Stream } from "effect"; +import { Deferred, Effect, Exit, FileSystem, Layer, Path, Sink, Stream } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import { type ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { beforeEach } from "vitest"; @@ -79,6 +78,16 @@ function mockDockerSpawner( }; } +const testServices = ( + mock: ReturnType<typeof mockDockerSpawner>, + out: ReturnType<typeof mockOutput>, +) => + Layer.mergeAll( + BunServices.layer, + out.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), + ); + function baseInput(workdir: string): LegacyEdgeRuntimeBringUpInput { return { projectId: "proj", @@ -111,13 +120,14 @@ function baseInput(workdir: string): LegacyEdgeRuntimeBringUpInput { function envEntries(runCall: { args: ReadonlyArray<string>; env?: Readonly<Record<string, string>>; -}) { +}): Effect.Effect<ReadonlyArray<string>, PlatformError> { const envFileArgIndex = runCall.args.indexOf("--env-file"); const envFilePath = runCall.args[envFileArgIndex + 1]; - expect(envFilePath).toBeDefined(); - return readFileSync(envFilePath!, "utf8") - .split("\n") - .filter((line) => line.length > 0); + return Effect.gen(function* () { + expect(envFilePath).toBeDefined(); + const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(BunServices.layer)); + return (yield* fs.readFileString(envFilePath!)).split("\n").filter((line) => line.length > 0); + }).pipe(Effect.provide(BunServices.layer)); } describe("legacyStartEdgeRuntimeContainer", () => { @@ -126,9 +136,17 @@ describe("legacyStartEdgeRuntimeContainer", () => { // An empty functions directory — every scenario here has zero declared // functions (`configDeclaredFunctions`/`configFunctions` in `baseInput`), // so nothing under it is ever read; it only needs to exist. - beforeEach(() => { - mkdirSync(join(tempWorkdir.current, "supabase", "functions"), { recursive: true }); - }); + beforeEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(BunServices.layer)); + const path = yield* Path.Path.pipe(Effect.provide(BunServices.layer)); + yield* fs.makeDirectory(path.join(tempWorkdir.current, "supabase", "functions"), { + recursive: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); it.effect( "sends the real internal db url (db container name, port 5432, config.db.password) — NOT functions serve's `db`-alias default", @@ -138,11 +156,10 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); - const entries = envEntries(mock.runCall!); + const entries = yield* envEntries(mock.runCall!); expect(entries).toContain( "SUPABASE_DB_URL=postgresql://postgres:postgres@supabase_db_proj:5432/postgres", ); @@ -155,11 +172,10 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); - const entries = envEntries(mock.runCall!); + const entries = yield* envEntries(mock.runCall!); expect(entries).toContain("SUPABASE_ANON_KEY=anon.jwt.value"); expect(entries).toContain("SUPABASE_SERVICE_ROLE_KEY=service-role.jwt.value"); expect(entries).toContain( @@ -180,8 +196,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); const runCall = mock.runCall!; @@ -198,8 +213,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); const runCall = mock.runCall!; @@ -213,9 +227,11 @@ describe("legacyStartEdgeRuntimeContainer", () => { it.effect("sets --workdir once an enabled function mounts the project root (#6035)", () => Effect.gen(function* () { const slug = "hello"; - const entrypoint = join(tempWorkdir.current, "supabase", "functions", slug, "index.ts"); - mkdirSync(join(tempWorkdir.current, "supabase", "functions", slug), { recursive: true }); - writeFileSync(entrypoint, "Deno.serve(() => new Response('ok'));"); + const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(BunServices.layer)); + const path = yield* Path.Path.pipe(Effect.provide(BunServices.layer)); + const entrypoint = path.join(tempWorkdir.current, "supabase", "functions", slug, "index.ts"); + yield* fs.makeDirectory(path.dirname(entrypoint), { recursive: true }); + yield* fs.writeFileString(entrypoint, "Deno.serve(() => new Response('ok'));"); const fnConfig = { enabled: true, @@ -234,10 +250,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { configDeclaredFunctions: { [slug]: fnConfig }, configFunctions: { [slug]: fnConfig }, rawConfigFunctions: { [slug]: fnConfig }, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), - ); + }).pipe(Effect.provide(testServices(mock, out))); const args = mock.runCall!.args; expect(args[args.indexOf("--workdir") + 1]).toBe(tempWorkdir.current); @@ -250,8 +263,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); expect(mock.runCall!.args).not.toContain("--workdir"); @@ -266,8 +278,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); const runCall = mock.runCall!; @@ -287,10 +298,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { image: "registry.example.com/supabase/edge-runtime:v1.99.9", }; - yield* legacyStartEdgeRuntimeContainer(input).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), - ); + yield* legacyStartEdgeRuntimeContainer(input).pipe(Effect.provide(testServices(mock, out))); const runCall = mock.runCall!; const networkIndex = runCall.args.indexOf("--network"); @@ -307,8 +315,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); const containerSteps = mock.calls @@ -358,8 +365,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); const error = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), Effect.flip, ); @@ -386,8 +392,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); const error = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), Effect.flip, ); @@ -409,8 +414,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); const started = yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); expect(started.containerId).toBe("supabase_edge_runtime_proj"); @@ -424,7 +428,9 @@ describe("legacyStartEdgeRuntimeContainer", () => { Effect.gen(function* () { const mock = mockDockerSpawner(); const out = mockOutput(); - const stagingDir = join( + const fs = yield* FileSystem.FileSystem.pipe(Effect.provide(BunServices.layer)); + const path = yield* Path.Path.pipe(Effect.provide(BunServices.layer)); + const stagingDir = path.join( tempWorkdir.current, "supabase", ".temp", @@ -434,8 +440,8 @@ describe("legacyStartEdgeRuntimeContainer", () => { // Simulates a leftover from an earlier invocation (e.g. `functions serve`'s watch-mode // restart loop) that was never reclaimed — `writeDockerEnvFile`'s own header explains why // this path is deterministic/reused rather than a fresh mkdtemp per call. - mkdirSync(join(stagingDir, "env"), { recursive: true }); - writeFileSync(join(stagingDir, "env", "docker.env"), "STALE=1"); + yield* fs.makeDirectory(path.join(stagingDir, "env"), { recursive: true }); + yield* fs.writeFileString(path.join(stagingDir, "env", "docker.env"), "STALE=1"); const input = { ...baseInput(tempWorkdir.current), @@ -447,14 +453,11 @@ describe("legacyStartEdgeRuntimeContainer", () => { }; const exit = yield* Effect.exit( - legacyStartEdgeRuntimeContainer(input).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), - ), + legacyStartEdgeRuntimeContainer(input).pipe(Effect.provide(testServices(mock, out))), ); expect(Exit.isFailure(exit)).toBe(true); - expect(existsSync(stagingDir)).toBe(false); + expect(yield* fs.exists(stagingDir)).toBe(false); }), ); @@ -466,8 +469,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); expect( @@ -484,8 +486,7 @@ describe("legacyStartEdgeRuntimeContainer", () => { const out = mockOutput(); yield* legacyStartEdgeRuntimeContainer(baseInput(tempWorkdir.current)).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, mock.spawner), - Effect.provide(out.layer), + Effect.provide(testServices(mock, out)), ); expect(out.stderrText).not.toContain("Setting up Edge Functions runtime..."); diff --git a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts index 2d85b4d64c..49bdf7ae2d 100644 --- a/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/legacy/commands/start/services/edge-runtime.service.ts @@ -69,6 +69,8 @@ export interface LegacyEdgeRuntimeBringUpInput { readonly networkId: string; /** `utils.Config.EdgeRuntime.Image`, already resolved/pulled by the caller (`image-prepull.ts`/`legacyResolveEdgeRuntimeImage`). */ readonly image: string; + /** Go's merged project environment, used for Bitbucket's named-volume restriction. */ + readonly projectEnvValues?: Readonly<Record<string, string>>; /** * `cliConfig.workdir` in `start.handler.ts` — used as `functions serve`'s * `projectRoot`/`flagCwd` (no separate "flag cwd" exists for `start`) and @@ -183,6 +185,7 @@ export const legacyStartEdgeRuntimeContainer = Effect.fn("legacy.start.edgeRunti legacyStartInternalDbPassword(input.dbUrl), ), image: input.image, + projectEnvValues: input.projectEnvValues, projectRoot: input.workdir, supabaseDir: `${input.workdir}/supabase`, flagCwd: input.workdir, diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.ts b/apps/cli/src/legacy/commands/start/services/kong.service.ts index ec72cd667c..b85b7638e0 100644 --- a/apps/cli/src/legacy/commands/start/services/kong.service.ts +++ b/apps/cli/src/legacy/commands/start/services/kong.service.ts @@ -49,8 +49,7 @@ * module calls `legacyGenerateGoJwt` itself. */ -import * as nodePath from "node:path"; -import { legacyResolveNotificationContentPath } from "../../../shared/legacy-config-validate.ts"; +import type { Path } from "effect"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; @@ -121,7 +120,7 @@ export function legacyBuildKongQueryToken(apiKeys: LegacyKongApiKeys): string { * never touches `process.env`. */ export function legacyResolveKongNginxWorkerProcesses( - projectEnvValues: Readonly<Record<string, string>> | undefined = undefined, + projectEnvValues?: Readonly<Record<string, string>>, ): string { return legacyEnvOrDefault("KONG_NGINX_WORKER_PROCESSES", "1", projectEnvValues); } @@ -158,18 +157,22 @@ export interface LegacyKongEmailTemplateMount { export function legacyBuildKongEmailTemplateBind( mount: LegacyKongEmailTemplateMount, workdir: string, + path: Path.Path, ): string | undefined { if (mount.contentPath.length === 0) return undefined; + const projectRootRelative = mount.contentPath.replace(/^\.\//u, ""); const hostPath = mount.notification - ? legacyResolveNotificationContentPath(workdir, mount.contentPath) - : nodePath.isAbsolute(mount.contentPath) + ? projectRootRelative.startsWith("supabase/") + ? path.resolve(workdir, mount.contentPath) + : path.join(workdir, "supabase", mount.contentPath) + : path.isAbsolute(mount.contentPath) ? mount.contentPath - : nodePath.resolve(workdir, mount.contentPath); - const dockerPath = nodePath.posix.join( + : path.resolve(workdir, mount.contentPath); + const dockerPath = path.join( LEGACY_KONG_NGINX_EMAIL_TEMPLATE_DIR, - `${mount.id}${nodePath.extname(hostPath)}`, + `${mount.id}${path.extname(hostPath)}`, ); - return `${hostPath}:${dockerPath}:rw`; + return `${hostPath}:${dockerPath.replaceAll(path.sep, "/")}:rw`; } const LEGACY_KONG_ENTRYPOINT_HEAD = @@ -190,6 +193,8 @@ export function legacyBuildKongEntrypointScript(nginxTemplate: string): string { } export interface LegacyKongContainerSpecInput { + /** Platform path service used for host and in-container path derivation. */ + readonly path: Path.Path; /** `config.api.kong_image`, already resolved/pulled by the caller. */ readonly image: string; /** `legacyServiceContainerName("kong", projectId)`. */ @@ -289,7 +294,7 @@ export function legacyBuildKongContainerSpec( }); const binds = (input.emailTemplateMounts ?? []) - .map((mount) => legacyBuildKongEmailTemplateBind(mount, input.workdir)) + .map((mount) => legacyBuildKongEmailTemplateBind(mount, input.workdir, input.path)) .filter((bind): bind is string => bind !== undefined); const dockerPort = input.apiTlsEnabled ? 8443 : 8000; diff --git a/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts index 61f219e2b0..164cf68226 100644 --- a/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/kong.service.unit.test.ts @@ -1,7 +1,8 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; +import * as EffectPath from "effect/Path"; import { legacyBuildKongBearerToken, @@ -14,6 +15,8 @@ import { type LegacyKongContainerSpecInput, } from "./kong.service.ts"; +const testPath = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); + const apiKeys: LegacyKongApiKeys = { secretKey: "sb_secret_abc", serviceRoleKey: "service-role-jwt", @@ -57,48 +60,73 @@ describe("legacyResolveKongNginxWorkerProcesses", () => { describe("legacyBuildKongEmailTemplateBind", () => { test("returns undefined for an empty contentPath (start.go:528-530)", () => { expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "" }, "/work"), + legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "" }, "/work", testPath), ).toBeUndefined(); }); test("resolves a relative contentPath against workdir (start.go:531-538)", () => { expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "invite.html" }, "/work"), + legacyBuildKongEmailTemplateBind( + { id: "invite", contentPath: "invite.html" }, + "/work", + testPath, + ), ).toBe("/work/invite.html:/home/kong/templates/email/invite.html:rw"); }); test("notification mounts fall back to the legacy supabase-relative file", () => { - const workdir = mkdtempSync(join(tmpdir(), "kong-email-bind-")); - try { - mkdirSync(join(workdir, "supabase", "templates"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "templates", "n.html"), "<p>x</p>"); - expect( - legacyBuildKongEmailTemplateBind( - { - id: "password_changed_notification", - contentPath: "./templates/n.html", - notification: true, - }, - workdir, - ), - ).toBe( - `${join(workdir, "supabase", "templates", "n.html")}:/home/kong/templates/email/password_changed_notification.html:rw`, - ); - // template mounts keep plain workdir resolution even when the file is absent - expect( - legacyBuildKongEmailTemplateBind( - { id: "invite", contentPath: "./templates/n.html" }, - workdir, - ), - ).toBe(`${join(workdir, "templates", "n.html")}:/home/kong/templates/email/invite.html:rw`); - } finally { - rmSync(workdir, { recursive: true, force: true }); - } + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const workdir = yield* Effect.acquireRelease( + fs.makeTempDirectory({ directory: tmpdir(), prefix: "kong-email-bind-" }), + (directory) => + fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ); + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(workdir, "supabase", "templates"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "templates", "n.html"), + "<p>x</p>", + ); + expect( + legacyBuildKongEmailTemplateBind( + { + id: "password_changed_notification", + contentPath: "./templates/n.html", + notification: true, + }, + workdir, + testPath, + ), + ).toBe( + `${path.join(workdir, "supabase", "templates", "n.html")}:/home/kong/templates/email/password_changed_notification.html:rw`, + ); + // template mounts keep plain workdir resolution even when the file is absent + expect( + legacyBuildKongEmailTemplateBind( + { id: "invite", contentPath: "./templates/n.html" }, + workdir, + testPath, + ), + ).toBe( + `${path.join(workdir, "templates", "n.html")}:/home/kong/templates/email/invite.html:rw`, + ); + }), + ).pipe(Effect.provide(BunServices.layer)), + ); }); test("leaves an absolute contentPath untouched", () => { expect( - legacyBuildKongEmailTemplateBind({ id: "invite", contentPath: "/abs/invite.html" }, "/work"), + legacyBuildKongEmailTemplateBind( + { id: "invite", contentPath: "/abs/invite.html" }, + "/work", + testPath, + ), ).toBe("/abs/invite.html:/home/kong/templates/email/invite.html:rw"); }); @@ -107,6 +135,7 @@ describe("legacyBuildKongEmailTemplateBind", () => { legacyBuildKongEmailTemplateBind( { id: "invite_notification", contentPath: "invite" }, "/work", + testPath, ), ).toBe("/work/invite:/home/kong/templates/email/invite_notification:rw"); }); @@ -131,6 +160,7 @@ describe("legacyBuildKongEntrypointScript", () => { }); const base: LegacyKongContainerSpecInput = { + path: testPath, image: "supabase/kong:3.0.0", containerName: "supabase_kong_proj", networkId: "supabase_network_proj", diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.ts index 229ccef273..0a170bbca1 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.ts @@ -15,7 +15,7 @@ * GCP service-account JSON). */ -import { join } from "node:path"; +import type { Path } from "effect"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; @@ -55,6 +55,8 @@ const LEGACY_LOGFLARE_ENTRYPOINT_SCRIPT = "cat <<'EOF' > run.sh && sh run.sh\n./logflare eval Logflare.Release.migrate &&\n./logflare start --sname logflare\nEOF\n"; export interface LegacyLogflareContainerSpecInput { + /** Platform path service used for host path derivation. */ + readonly path: Path.Path; /** * The already-resolved `config.analytics.image`. Not part of the decoded * `@supabase/config` schema; resolution is the caller's responsibility. @@ -122,7 +124,7 @@ export function legacyBuildLogflareContainerSpec( const binds: Array<string> = []; if (input.backend === "bigquery") { - const hostJwtPath = join(input.workdir, input.gcpJwtPath); + const hostJwtPath = input.path.join(input.workdir, input.gcpJwtPath); binds.push(`${hostJwtPath}:/opt/app/rel/logflare/bin/gcloud.json`); env.GOOGLE_DATASET_ID_APPEND = "_prod"; env.GOOGLE_PROJECT_ID = input.gcpProjectId; diff --git a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts index 7a9cf7aa93..38da7e2541 100644 --- a/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/logflare.service.unit.test.ts @@ -1,13 +1,17 @@ -import { join } from "node:path"; - +import { BunPath } from "@effect/platform-bun"; import { describe, expect, test } from "vitest"; +import { Effect } from "effect"; +import * as EffectPath from "effect/Path"; import { legacyBuildLogflareContainerSpec, type LegacyLogflareContainerSpecInput, } from "./logflare.service.ts"; +const testPath = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); + const base: LegacyLogflareContainerSpecInput = { + path: testPath, image: "supabase/logflare:1.0.0", projectId: "proj", networkId: "supabase_network_proj", @@ -95,7 +99,7 @@ describe("legacyBuildLogflareContainerSpec", () => { expect(spec.env.POSTGRES_BACKEND_URL).toBeUndefined(); expect(spec.env.POSTGRES_BACKEND_SCHEMA).toBeUndefined(); expect(spec.binds).toEqual([ - `${join("/workdir", "gcloud.json")}:/opt/app/rel/logflare/bin/gcloud.json`, + `${testPath.join("/workdir", "gcloud.json")}:/opt/app/rel/logflare/bin/gcloud.json`, ]); }); @@ -106,6 +110,8 @@ describe("legacyBuildLogflareContainerSpec", () => { gcpJwtPath: "", workdir: "/workdir", }); - expect(spec.binds).toEqual([`${join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`]); + expect(spec.binds).toEqual([ + `${testPath.join("/workdir", "")}:/opt/app/rel/logflare/bin/gcloud.json`, + ]); }); }); diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.ts b/apps/cli/src/legacy/commands/start/services/studio.service.ts index 8b38df7955..ea61f67452 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.ts @@ -20,7 +20,7 @@ * and {@link legacyBuildStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`. */ -import { join } from "node:path"; +import type { Path } from "effect"; import { legacyToDockerPath } from "../../../shared/legacy-docker-path.ts"; import type { LegacyStartContainerSpec } from "../../../shared/db-bootstrap/docker-create-args.ts"; @@ -41,6 +41,8 @@ const STUDIO_NETWORK_ALIASES = ["studio"]; const LOGFLARE_PRIVATE_ACCESS_TOKEN = "api-key"; export interface LegacyBuildStudioEnvInput { + /** Platform path service used for host path derivation. */ + readonly path: Path.Path; /** The db password — becomes `POSTGRES_PASSWORD`. */ readonly dbPassword: string; /** @@ -134,7 +136,7 @@ export function legacyBuildStudioEnv(input: LegacyBuildStudioEnvInput): Record<s NEXT_PUBLIC_ENABLE_LOGS: String(input.analyticsEnabled), NEXT_ANALYTICS_BACKEND_PROVIDER: input.analyticsBackend, EDGE_FUNCTIONS_MANAGEMENT_FOLDER: legacyToDockerPath( - join(input.workdir, "supabase", "functions"), + input.path.join(input.workdir, "supabase", "functions"), ), SNIPPETS_MANAGEMENT_FOLDER: input.containerSnippetsPath, // Ref: https://github.com/vercel/next.js/issues/51684#issuecomment-1612834913 @@ -144,6 +146,8 @@ export function legacyBuildStudioEnv(input: LegacyBuildStudioEnvInput): Record<s } export interface LegacyStudioContainerInput { + /** Platform path service used for host path derivation. */ + readonly path: Path.Path; /** `config.studio.image`, already resolved/pulled by the caller. */ readonly image: string; /** `legacyServiceContainerName("studio", projectId)`. */ @@ -161,7 +165,7 @@ export interface LegacyStudioContainerInput { */ readonly functionBinds: ReadonlyArray<string>; /** Every value {@link legacyBuildStudioEnv} needs, minus the path this builder derives itself. */ - readonly env: Omit<LegacyBuildStudioEnvInput, "containerSnippetsPath">; + readonly env: Omit<LegacyBuildStudioEnvInput, "containerSnippetsPath" | "path">; } /** @@ -173,7 +177,7 @@ export interface LegacyStudioContainerInput { export function legacyBuildStudioContainerSpec( input: LegacyStudioContainerInput, ): LegacyStartContainerSpec { - const hostSnippetsPath = join(input.env.workdir, "supabase", "snippets"); + const hostSnippetsPath = input.path.join(input.env.workdir, "supabase", "snippets"); const containerSnippetsPath = legacyToDockerPath(hostSnippetsPath); // Order-preserving dedup; `Set` iteration order is first-seen-wins. @@ -184,7 +188,7 @@ export function legacyBuildStudioContainerSpec( return { image: input.image, containerName: input.containerName, - env: legacyBuildStudioEnv({ ...input.env, containerSnippetsPath }), + env: legacyBuildStudioEnv({ ...input.env, path: input.path, containerSnippetsPath }), binds, healthcheck: { test: [ diff --git a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts index 80728d823d..b8fc501185 100644 --- a/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/services/studio.service.unit.test.ts @@ -1,4 +1,7 @@ +import { BunPath } from "@effect/platform-bun"; import { describe, expect, test } from "vitest"; +import { Effect } from "effect"; +import * as EffectPath from "effect/Path"; import { legacyBuildStudioContainerSpec, @@ -6,7 +9,10 @@ import { type LegacyBuildStudioEnvInput, } from "./studio.service.ts"; +const testPath = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); + const baseEnvInput: LegacyBuildStudioEnvInput = { + path: testPath, dbPassword: "postgres", workdir: "/project", containerSnippetsPath: "/project/supabase/.temp/snippets", @@ -112,6 +118,7 @@ describe("legacyBuildStudioEnv", () => { describe("legacyBuildStudioContainerSpec", () => { const baseSpecInput = { + path: testPath, image: "supabase/studio:2026.07.07-sha-a6a04f2", containerName: "supabase_studio_proj", networkId: "supabase_network_proj", diff --git a/apps/cli/src/legacy/commands/start/services/vector.service.ts b/apps/cli/src/legacy/commands/start/services/vector.service.ts index 496f740566..939f968a40 100644 --- a/apps/cli/src/legacy/commands/start/services/vector.service.ts +++ b/apps/cli/src/legacy/commands/start/services/vector.service.ts @@ -195,7 +195,7 @@ export function legacyResolveVectorDockerSocketPlan( return { env, binds, securityOpt, isNpipe: parsed.scheme === "npipe" }; } -function collectText(stream: Stream.Stream<Uint8Array, unknown>) { +function collectText<E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -229,7 +229,12 @@ function legacyInspectDockerContextHost(spawner: Spawner): Effect.Effect<string, ) .pipe(Effect.mapError(() => "failed to spawn docker")); const [exitCode, stdout] = yield* Effect.all( - [child.exitCode.pipe(Effect.map(Number)), collectText(child.stdout)], + [ + child.exitCode.pipe(Effect.map(Number)), + collectText(child.stdout).pipe( + Effect.mapError(() => "failed to read docker context inspect output"), + ), + ], { concurrency: "unbounded" }, ).pipe(Effect.mapError(() => "failed to read docker context inspect output")); if (exitCode !== 0) { diff --git a/apps/cli/src/legacy/commands/start/start.command.ts b/apps/cli/src/legacy/commands/start/start.command.ts index 0d1ce71928..3edc489680 100644 --- a/apps/cli/src/legacy/commands/start/start.command.ts +++ b/apps/cli/src/legacy/commands/start/start.command.ts @@ -2,18 +2,23 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../auth/legacy-platform-api-factory.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyDbConnectionLayer } from "../../shared/legacy-db-connection.layer.ts"; import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; import { legacyDockerRunLayer } from "../../shared/legacy-docker-run.layer.ts"; import { legacyEdgeRuntimeScriptLayer } from "../../shared/legacy-edge-runtime-script.layer.ts"; +import { legacyLocalGatewayHttpClientLayer } from "../../shared/legacy-local-gateway-http-client.ts"; +import { legacyIdentityStitchLayer } from "../../shared/legacy-identity-stitch.ts"; import { legacyPgDeltaSslProbeLayer } from "../../shared/legacy-pgdelta-ssl-probe.layer.ts"; import { legacyStringSliceFlag } from "../../shared/legacy-string-slice-flag.ts"; import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; +import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { LEGACY_START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import { legacyStart } from "./start.handler.ts"; @@ -43,10 +48,13 @@ const config = { export type LegacyStartFlags = CliCommand.Command.Config.Infer<typeof config>; -// `start` makes no Management API calls and talks directly to Docker, so it -// deliberately avoids `legacyManagementApiRuntimeLayer` — -// it provides only the services the handler + instrumentation consume, mirroring -// `stop`/`status`'s runtime shape. `ChildProcessSpawner`/`ProcessControl`/`RuntimeInfo` +// `start` makes no eager Management API calls and talks directly to Docker, so it +// deliberately avoids `legacyManagementApiRuntimeLayer` (the eager API stack). It +// does expose the lazy `LegacyPlatformApiFactory` because the shared local-project +// context can take a linked/config interpolation path; constructing that factory +// does not resolve credentials or make a request, and the local Docker path never +// invokes it. The remaining services are the handler + instrumentation runtime, +// mirroring `stop`/`status`'s shape. `ChildProcessSpawner`/`ProcessControl`/`RuntimeInfo` // are not listed here: they come from `BunServices`/`processControlLayer`/ // `runtimeInfoLayer` in the root runtime (`shared/cli/run.ts`), the same way // `stop`/`status` rely on the former. `HttpClient.HttpClient` is NOT provided by the @@ -64,6 +72,16 @@ export type LegacyStartFlags = CliCommand.Command.Config.Infer<typeof config>; // for its own call to that function (`push.layers.ts`). const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); +const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), +); +const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + Layer.provide(legacyIdentityStitchLayer), +); const edgeRuntime = legacyEdgeRuntimeScriptLayer.pipe( Layer.provide(legacyDockerRunLayer), Layer.provide(cliConfig), @@ -76,6 +94,9 @@ const legacyStartRuntimeLayer = Layer.mergeAll( legacyDockerRunLayer, legacyDbConnectionLayer, httpClient, + platformApiFactory, + stdinLayer, + legacyLocalGatewayHttpClientLayer, edgeRuntime, legacyPgDeltaSslProbeLayer, ); diff --git a/apps/cli/src/legacy/commands/start/start.command.unit.test.ts b/apps/cli/src/legacy/commands/start/start.command.unit.test.ts index 1d50000fd9..4097f0bbb4 100644 --- a/apps/cli/src/legacy/commands/start/start.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/start/start.command.unit.test.ts @@ -5,83 +5,76 @@ import { normalizeCause } from "../../../shared/output/normalize-error.ts"; import { legacyStartExcludeFlag } from "./start.command.ts"; describe("legacy start --exclude flag (pflag StringSlice parity)", () => { - test("splits a comma-separated value into multiple exclusions", async () => { - const [, exclude] = await Effect.runPromise( + test("splits a comma-separated value into multiple exclusions", () => + Effect.runPromise( legacyStartExcludeFlag .parse({ flags: { exclude: ["gotrue,realtime"] }, arguments: [] }) .pipe(Effect.provide(BunServices.layer)), - ); + ).then(([, exclude]) => { + expect(exclude).toEqual(["gotrue", "realtime"]); + })); - expect(exclude).toEqual(["gotrue", "realtime"]); - }); - - test("accumulates repeated occurrences, each CSV-split", async () => { - const [, exclude] = await Effect.runPromise( + test("accumulates repeated occurrences, each CSV-split", () => + Effect.runPromise( legacyStartExcludeFlag .parse({ flags: { exclude: ["gotrue,realtime", "studio"] }, arguments: [] }) .pipe(Effect.provide(BunServices.layer)), - ); - - expect(exclude).toEqual(["gotrue", "realtime", "studio"]); - }); + ).then(([, exclude]) => { + expect(exclude).toEqual(["gotrue", "realtime", "studio"]); + })); - test("defaults to an empty array when unset", async () => { - const [, exclude] = await Effect.runPromise( + test("defaults to an empty array when unset", () => + Effect.runPromise( legacyStartExcludeFlag .parse({ flags: {}, arguments: [] }) .pipe(Effect.provide(BunServices.layer)), - ); - - expect(exclude).toEqual([]); - }); + ).then(([, exclude]) => { + expect(exclude).toEqual([]); + })); - test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { + test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => { // Verified against pflag's actual CSV behavior (CLI-2005): `start -x $'a\nb"c'` // raises no parse error and excludes only `a` — pflag calls // `csv.Reader.Read()` once, so the malformed second line is silently dropped. - const [, exclude] = await Effect.runPromise( + return Effect.runPromise( legacyStartExcludeFlag .parse({ flags: { exclude: ['a\nb"c'] }, arguments: [] }) .pipe(Effect.provide(BunServices.layer)), - ); - - expect(exclude).toEqual(["a"]); + ).then(([, exclude]) => { + expect(exclude).toEqual(["a"]); + }); }); - test("rejects malformed CSV with pflag's shorthand-framed diagnostic", async () => { - const exit = await Effect.runPromise( + test("rejects malformed CSV with pflag's shorthand-framed diagnostic", () => + Effect.runPromise( legacyStartExcludeFlag .parse({ flags: { exclude: ['a"b'] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // The flag has both a shorthand and long spelling, so pflag frames the - // diagnostic with BOTH spellings — `-x, --exclude` — regardless of which - // one was typed. Verified against pflag's actual output (CLI-2005). - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "a\\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit), + ).then((exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + // The flag has both a shorthand and long spelling, so pflag frames the + // diagnostic with BOTH spellings — `-x, --exclude` — regardless of which + // one was typed. Verified against pflag's actual output (CLI-2005). + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "a\\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field', + ); + } + })); - test("rejects a blank-only value with pflag's EOF diagnostic", async () => { + test("rejects a blank-only value with pflag's EOF diagnostic", () => // Verified against pflag's actual output (CLI-2005): `start -x $'\n'` → // `invalid argument "\n" for "-x, --exclude" flag: EOF`. - const exit = await Effect.runPromise( + Effect.runPromise( legacyStartExcludeFlag .parse({ flags: { exclude: ["\n"] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\n" for "-x, --exclude" flag: EOF', - ); - } - }); + .pipe(Effect.provide(BunServices.layer), Effect.exit), + ).then((exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\n" for "-x, --exclude" flag: EOF', + ); + } + })); }); diff --git a/apps/cli/src/legacy/commands/start/start.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.e2e.test.ts index 4d8408d9c1..d743824e1a 100644 --- a/apps/cli/src/legacy/commands/start/start.e2e.test.ts +++ b/apps/cli/src/legacy/commands/start/start.e2e.test.ts @@ -1,6 +1,5 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem } from "effect"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { runSupabase, stripAnsi } from "../../../../tests/helpers/cli.ts"; @@ -10,13 +9,23 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase start (legacy)", () => { let projectDir: string; - beforeEach(() => { - projectDir = mkdtempSync(join(tmpdir(), "sb-start-e2e-")); - }); + beforeEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + projectDir = yield* fs.makeTempDirectory({ prefix: "sb-start-e2e-" }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterEach(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); + afterEach(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); // Golden-path smoke test for the real subprocess boundary: exclude-flag // validation runs unconditionally as the handler's very first step, before @@ -34,17 +43,16 @@ describe("supabase start (legacy)", () => { test( "prints the invalid --exclude warning then fails cleanly on the Docker call", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["start", "--exclude", "bogus"], { + () => + runSupabase(["start", "--exclude", "bogus"], { entrypoint: "legacy", cwd: projectDir, env: { DOCKER_HOST: "tcp://127.0.0.1:1" }, - }); - - expect(stripAnsi(stderr), `stdout:\n${stdout}\nstderr:\n${stderr}`).toContain( - "WARNING: The following container names are not valid to exclude: bogus", - ); - expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).not.toBe(0); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(stripAnsi(stderr), `stdout:\n${stdout}\nstderr:\n${stderr}`).toContain( + "WARNING: The following container names are not valid to exclude: bogus", + ); + expect(exitCode, `stdout:\n${stdout}\nstderr:\n${stderr}`).not.toBe(0); + }), ); }); diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index fd9ffb2a73..d2fc3c62c7 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -4,7 +4,6 @@ */ import { inferFunctionsManifest, resolveProjectSubtree } from "@supabase/config"; import { Effect, FileSystem, Option, Path, Result } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { CLI_VERSION } from "../../../shared/cli/version.ts"; @@ -37,17 +36,14 @@ import { import { legacyIsContainerNotFoundMessage } from "../../shared/legacy-container-cli.ts"; import { legacyCheckDbToml } from "../../shared/legacy-db-config.toml-read.ts"; import { legacyResolveEdgeRuntimeImage } from "../../shared/legacy-edge-runtime-image.ts"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "../../shared/legacy-storage-credentials.ts"; +import { legacyResolveStorageCredentials } from "../../shared/legacy-storage-credentials.ts"; +import { LegacyLocalGatewayHttpClient } from "../../shared/legacy-local-gateway-http-client.ts"; import { legacyCollectDotenvPrivateKeys, legacyDecryptSecret, legacyIsEncryptedSecret, } from "../../shared/legacy-vault-decrypt.ts"; import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; -import { legacyConfigureLoopbackProxyBypass } from "../../shared/legacy-hostname.ts"; import { legacyCliProjectFilterValue, legacyServiceContainerIds, @@ -248,107 +244,117 @@ function resolveGotrueEnvInput(params: { readonly kongContainerName: string; readonly mailpitContainerName: string; readonly resolvedEmail: LegacyResolvedAuthEmail; -}): Omit<LegacyBuildGotrueEnvInput, "dbHost" | "dbPassword"> { - const { context, values, workdir, kongContainerName, mailpitContainerName, resolvedEmail } = - params; - const { config, projectEnvValues, loaded } = context; - const document = loaded?.document; - - const inbucketEnabled = legacyEnvOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - // `[auth.email.smtp]`'s presence-based `enabled` default — reading the - // schema-decoded `config.auth.email.smtp` here would always see `enabled: - // false` when the key is merely absent from the TOML table (`@supabase/ - // config`'s decode-time default), silently falling back to Mailpit even - // when a real SMTP server is configured. `legacyResolveAuthEmailSmtp` - // resolves this correctly off the raw document, same as the passkey/ - // webauthn/external-provider reads below. - const resolvedSmtp = legacyResolveAuthEmailSmtp(asRecord(document?.["auth"]), projectEnvValues); - const smtp = - resolvedSmtp?.enabled === true - ? { - host: resolvedSmtp.host, - port: resolvedSmtp.port, - user: resolvedSmtp.user, - pass: resolvedSmtp.pass, - adminEmail: resolvedSmtp.adminEmail, - senderName: resolvedSmtp.senderName, - } - : undefined; - // Same generic-Viper-override gap as `inbucketEnabled` above, for - // `local_smtp.admin_email`/`sender_name` — value-typed fields, so no - // raw-document presence gate needed, matching `local_smtp.port`'s - // existing treatment. - const mailpitAdminEmail = legacyEnvOverride( - "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", - config.local_smtp.admin_email, - projectEnvValues, - ); - const mailpitSenderName = legacyEnvOverride( - "SUPABASE_LOCAL_SMTP_SENDER_NAME", - config.local_smtp.sender_name, - projectEnvValues, - ); - const mailpit = - smtp === undefined && inbucketEnabled - ? { - containerName: mailpitContainerName, - adminEmail: mailpitAdminEmail, - senderName: mailpitSenderName, - } - : undefined; +}): Effect.Effect< + Omit<LegacyBuildGotrueEnvInput, "dbHost" | "dbPassword">, + Error, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const { context, values, workdir, kongContainerName, mailpitContainerName, resolvedEmail } = + params; + const { config, projectEnvValues, loaded } = context; + const document = loaded?.document; + + const inbucketEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + // `[auth.email.smtp]`'s presence-based `enabled` default — reading the + // schema-decoded `config.auth.email.smtp` here would always see `enabled: + // false` when the key is merely absent from the TOML table (`@supabase/ + // config`'s decode-time default), silently falling back to Mailpit even + // when a real SMTP server is configured. `legacyResolveAuthEmailSmtp` + // resolves this correctly off the raw document, same as the passkey/ + // webauthn/external-provider reads below. + const resolvedSmtp = legacyResolveAuthEmailSmtp(asRecord(document?.["auth"]), projectEnvValues); + const smtp = + resolvedSmtp?.enabled === true + ? { + host: resolvedSmtp.host, + port: resolvedSmtp.port, + user: resolvedSmtp.user, + pass: resolvedSmtp.pass, + adminEmail: resolvedSmtp.adminEmail, + senderName: resolvedSmtp.senderName, + } + : undefined; + // Same generic-Viper-override gap as `inbucketEnabled` above, for + // `local_smtp.admin_email`/`sender_name` — value-typed fields, so no + // raw-document presence gate needed, matching `local_smtp.port`'s + // existing treatment. + const mailpitAdminEmail = legacyEnvOverride( + "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", + config.local_smtp.admin_email, + projectEnvValues, + ); + const mailpitSenderName = legacyEnvOverride( + "SUPABASE_LOCAL_SMTP_SENDER_NAME", + config.local_smtp.sender_name, + projectEnvValues, + ); + const mailpit = + smtp === undefined && inbucketEnabled + ? { + containerName: mailpitContainerName, + adminEmail: mailpitAdminEmail, + senderName: mailpitSenderName, + } + : undefined; - const { passkeyEnabled, webauthn } = legacyResolveGotruePasskeyWebauthn( - document, - projectEnvValues, - ); - const externalProviders = legacyResolveAuthExternalProviders( - asRecord(document?.["auth"]), - config.auth.external, - projectEnvValues, - ); - const authExternalUrl = legacyResolveAuthExternalUrl(document, projectEnvValues); - - return { - apiUrl: values.apiUrl, - authExternalUrl, - jwtSecret: values.jwtSecret, - jwtIssuer: values.authJwtIssuer, - jwtExpiry: values.authJwtExpiry, - siteUrl: values.authSiteUrl, - additionalRedirectUrls: values.authAdditionalRedirectUrls, - enableSignup: values.authEnableSignup, - enableAnonymousSignIns: values.authEnableAnonymousSignIns, - enableRefreshTokenRotation: values.authEnableRefreshTokenRotation, - refreshTokenReuseInterval: values.authRefreshTokenReuseInterval, - enableManualLinking: values.authEnableManualLinking, - minimumPasswordLength: values.authMinimumPasswordLength, - passwordRequirements: values.authPasswordRequirements, - email: resolvedEmail, - kongContainerName, - smtp, - mailpit, - sms: legacyResolveAuthSms(asRecord(document?.["auth"]), config.auth.sms, projectEnvValues), - sessions: resolveGotrueSessions(config.auth.sessions, projectEnvValues), - mfa: legacyResolveAuthMfa(config.auth.mfa, projectEnvValues), - rateLimit: resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), - web3: legacyResolveGotrueWeb3(config.auth.web3, projectEnvValues), - oauthServer: legacyResolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), - hooks: legacyResolveAuthHooks(asRecord(document?.["auth"]), config.auth.hook, projectEnvValues), - captcha: legacyResolveAuthCaptcha( + const { passkeyEnabled, webauthn } = legacyResolveGotruePasskeyWebauthn( + document, + projectEnvValues, + ); + const externalProviders = legacyResolveAuthExternalProviders( asRecord(document?.["auth"]), - config.auth.captcha, + config.auth.external, projectEnvValues, - ), - passkeyEnabled, - webauthn, - externalProviders, - signingKeys: legacyResolveConfiguredSigningKeys(config, workdir, projectEnvValues), - }; + ); + const authExternalUrl = legacyResolveAuthExternalUrl(document, projectEnvValues); + + return { + apiUrl: values.apiUrl, + authExternalUrl, + jwtSecret: values.jwtSecret, + jwtIssuer: values.authJwtIssuer, + jwtExpiry: values.authJwtExpiry, + siteUrl: values.authSiteUrl, + additionalRedirectUrls: values.authAdditionalRedirectUrls, + enableSignup: values.authEnableSignup, + enableAnonymousSignIns: values.authEnableAnonymousSignIns, + enableRefreshTokenRotation: values.authEnableRefreshTokenRotation, + refreshTokenReuseInterval: values.authRefreshTokenReuseInterval, + enableManualLinking: values.authEnableManualLinking, + minimumPasswordLength: values.authMinimumPasswordLength, + passwordRequirements: values.authPasswordRequirements, + email: resolvedEmail, + kongContainerName, + smtp, + mailpit, + sms: legacyResolveAuthSms(asRecord(document?.["auth"]), config.auth.sms, projectEnvValues), + sessions: resolveGotrueSessions(config.auth.sessions, projectEnvValues), + mfa: legacyResolveAuthMfa(config.auth.mfa, projectEnvValues), + rateLimit: resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), + web3: legacyResolveGotrueWeb3(config.auth.web3, projectEnvValues), + oauthServer: legacyResolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), + hooks: legacyResolveAuthHooks( + asRecord(document?.["auth"]), + config.auth.hook, + projectEnvValues, + ), + captcha: legacyResolveAuthCaptcha( + asRecord(document?.["auth"]), + config.auth.captcha, + projectEnvValues, + ), + passkeyEnabled, + webauthn, + externalProviders, + signingKeys: yield* legacyResolveConfiguredSigningKeys(config, workdir, projectEnvValues), + }; + }); } /** @@ -393,6 +399,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const telemetryState = yield* LegacyTelemetryState; const analytics = yield* Analytics; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const localGatewayHttpClient = yield* LegacyLocalGatewayHttpClient; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; @@ -425,20 +432,20 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta cliConfig.workdir, (message) => new LegacyStartConfigLoadError({ message }), ); - const values = yield* Effect.try({ - try: () => - legacyResolveLocalConfigValues( - context.config, - context.hostname, - cliConfig.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - catch: (cause) => - new LegacyStartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const values = yield* legacyResolveLocalConfigValues( + context.config, + context.hostname, + cliConfig.workdir, + context.projectEnvValues, + context.loaded?.document, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStartInvalidConfigError({ + message: cause.message, + }), + ), + ); const { config, projectId, projectEnvValues } = context; // `SUPABASE_EXPERIMENTAL`/`--experimental`, read deep inside // `legacyStartDatabase`'s fresh-volume setup pipeline — resolved here @@ -590,11 +597,9 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // fails with `'functions[foo]' has invalid keys: env`. for (const [slug, func] of Object.entries(config.functions)) { if (Object.keys(func.env).length > 0) { - yield* Effect.fail( - new LegacyStartInvalidConfigError({ - message: `failed to parse config: decoding failed due to the following error(s):\n\n'functions[${slug}]' has invalid keys: env`, - }), - ); + return yield* new LegacyStartInvalidConfigError({ + message: `failed to parse config: decoding failed due to the following error(s):\n\n'functions[${slug}]' has invalid keys: env`, + }); } } // `legacyCheckDbToml` resolves `[db.vault]`/`[db.seed]`/`db.migrations.enabled`/the effective @@ -633,27 +638,27 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta excluded: ReadonlyArray<string>, precomputedLocal?: LegacyLocalConfigValues, ) { - const localState = yield* Effect.try({ - try: () => - legacyResolveStatusLocalState( - context.config, - context.hostname, - cliConfig.workdir, - context.projectEnvValues, - context.loaded?.document, - precomputedLocal, - ), - catch: (cause) => - new LegacyStatusInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const localState = yield* legacyResolveStatusLocalState( + context.config, + context.hostname, + cliConfig.workdir, + context.projectEnvValues, + context.loaded?.document, + precomputedLocal, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ); const containerIds = legacyStatusContainerIds(projectId); const state = legacyGateStatusState(localState, containerIds, excluded); return legacyStatusValuesFromState(state, new Map()); }); - const isBitbucketPipeline = legacyIsBitbucketPipeline(); + const isBitbucketPipeline = legacyIsBitbucketPipeline(context.projectEnvValues); // 3. Missing proceeds to startup; other inspect failures propagate. // Verified stopped stacks are recovered unless Bitbucket's lack of named volumes @@ -661,7 +666,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta const inspectDbState = legacyInspectContainerState(spawner, dbContainerId).pipe( Effect.catch((error) => legacyIsContainerNotFoundMessage(error.message) - ? Effect.succeed(undefined) + ? Effect.as(Effect.void, undefined) : Effect.fail(error), ), ); @@ -692,18 +697,14 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), ); if (!state.running) { - return yield* Effect.fail( - new LegacyStatusDbNotRunningError({ - message: `${dbContainerId} container is not running: ${state.status}`, - }), - ); + return yield* new LegacyStatusDbNotRunningError({ + message: `${dbContainerId} container is not running: ${state.status}`, + }); } if (state.health !== undefined && state.health !== "healthy") { - return yield* Effect.fail( - new LegacyStatusDbNotReadyError({ - message: `${dbContainerId} container is not ready: ${state.health}`, - }), - ); + return yield* new LegacyStatusDbNotReadyError({ + message: `${dbContainerId} container is not ready: ${state.health}`, + }); } } @@ -768,14 +769,19 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // 6. JWKS resolution — runs UNCONDITIONALLY, before any image pull, // regardless of whether auth/realtime/postgrest/storage end up enabled. - const jwks = yield* Effect.tryPromise({ - try: () => - legacyResolveLocalJwks(config, cliConfig.workdir, values.jwtSecret, projectEnvValues), - catch: (cause) => - new LegacyStartInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const jwks = yield* legacyResolveLocalJwks( + config, + cliConfig.workdir, + values.jwtSecret, + projectEnvValues, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStartInvalidConfigError({ + message: cause.message, + }), + ), + ); // Same treatment as `majorVersion` below, for the sibling // `edge_runtime.deno_version` -> image switch, applied before validation @@ -887,6 +893,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta Option.none(), Option.none(), cliConfig.workdir, + projectEnvValues, ) : new Set<string>(); @@ -900,9 +907,13 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta // flag/env precedence (shared with `db start` and the `functions` // Docker paths). const networkIdFlag = yield* LegacyNetworkIdFlag; + const envNetworkId = yield* legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + projectEnvValues, + ); const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), - envOverride: legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), + envOverride: envNetworkId, projectId, }); // Every container unconditionally gets the Linux-only @@ -993,7 +1004,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta apiTlsKeyPath.length > 0 ) { tlsCertContent = yield* fs - .readFileString(legacyResolveApiTlsPath(cliConfig.workdir, apiTlsCertPath)) + .readFileString(legacyResolveApiTlsPath(path, cliConfig.workdir, apiTlsCertPath)) .pipe( Effect.mapError( (cause) => @@ -1003,7 +1014,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta ), ); tlsKeyContent = yield* fs - .readFileString(legacyResolveApiTlsPath(cliConfig.workdir, apiTlsKeyPath)) + .readFileString(legacyResolveApiTlsPath(path, cliConfig.workdir, apiTlsKeyPath)) .pipe( Effect.mapError( (cause) => @@ -1244,6 +1255,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta case "logflare": return { spec: legacyBuildLogflareContainerSpec({ + path, image, projectId, networkId, @@ -1297,6 +1309,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta case "kong": { return { spec: legacyBuildKongContainerSpec({ + path, image, containerName: kongContainerName, networkId, @@ -1327,23 +1340,25 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta }; } - case "gotrue": + case "gotrue": { + const env = yield* resolveGotrueEnvInput({ + context, + values, + workdir: cliConfig.workdir, + kongContainerName, + mailpitContainerName, + resolvedEmail, + }); return { spec: legacyBuildGotrueContainerSpec({ image, projectId, networkId, dbUrl: values.dbUrl, - env: resolveGotrueEnvInput({ - context, - values, - workdir: cliConfig.workdir, - kongContainerName, - mailpitContainerName, - resolvedEmail, - }), + env, }), }; + } case "mailpit": return { @@ -1428,6 +1443,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta case "studio": { return { spec: legacyBuildStudioContainerSpec({ + path, image, containerName: studioContainerName, networkId, @@ -1652,7 +1668,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta yield* output.raw(`${legacyHealthWarningText(error)}\n`, "stderr"); return { kind: "postgresUnhealthyIgnored" as const }; } - return yield* Effect.fail(error); + return yield* error; } if (output.format === "text") { @@ -1717,11 +1733,9 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } const decrypted = legacyDecryptSecret(secretValue, dotenvPrivateKeys); if (!decrypted.ok) { - return yield* Effect.fail( - new LegacyStartInvalidConfigError({ - message: `failed to parse config: ${decrypted.error}`, - }), - ); + return yield* new LegacyStartInvalidConfigError({ + message: `failed to parse config: ${decrypted.error}`, + }); } edgeRuntimeSecrets[secretName] = decrypted.value; } @@ -1732,6 +1746,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectId, networkId, image: resolveImage(edgeRuntimeDefaultImage), + projectEnvValues, workdir: cliConfig.workdir, dbUrl: values.dbUrl, apiPort: values.apiPort, @@ -1852,11 +1867,9 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } // legacyCliProjectFilterValue("") targets every CLI-managed project; never use it here. if (projectId.length === 0) { - return yield* Effect.fail( - new LegacyStartInvalidConfigError({ - message: "Invalid config: project_id must contain at least one alphanumeric character.", - }), - ); + return yield* new LegacyStartInvalidConfigError({ + message: "Invalid config: project_id must contain at least one alphanumeric character.", + }); } let removedContainers: ReadonlyArray<LegacyContainerIdName> = []; yield* legacyDockerRemoveAll( @@ -1905,18 +1918,9 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta yield* output.raw(LEGACY_START_WAITING_FOR_HEALTH_CHECKS_MESSAGE, "stderr"); } // The PostgREST/Edge Runtime readiness probes go through Kong over HTTP(S) — - // when `api.tls.enabled`, Kong's local cert is self-signed, so the root - // runtime's `HttpClient.HttpClient` (built from `FetchHttpClient.layer` over - // plain `fetch`) would fail TLS verification on every probe and the health - // check would exhaust its full timeout even though the services are - // actually healthy. Resolve the same local Kong CA `legacySeedBucketsRun`'s - // own gateway calls already trust (`projectRef: ""` never touches the - // network — see `legacyResolveStorageCredentials`'s local branch) and - // override just the underlying `FetchHttpClient.Fetch` primitive — NOT the - // whole `HttpClient.HttpClient` layer — so this only takes effect for a - // `FetchHttpClient`-backed client (production) and is a no-op against a - // hand-rolled `HttpClient.make(...)` mock (this file's own integration - // tests), which never reads `FetchHttpClient.Fetch` at all. + // when `api.tls.enabled`, Kong's local cert is self-signed, so use the same + // local Kong CA as `legacySeedBucketsRun`'s gateway calls. The explicit + // local-gateway transport also bypasses ambient HTTP proxy settings. // // Folds the hoisted, env-overridden `apiEnabled`/`apiPort`/`apiTlsEnabled`/ // `apiTlsCertPath`/`apiTlsKeyPath`/`values.apiUrl` into `config` (not the @@ -1980,18 +1984,12 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta projectRef: "", config: effectiveLocalStorageConfig, }); - // Keep the synthetic value out of project dotenv resolution and container environments. - legacyConfigureLoopbackProxyBypass(); - const healthResult = yield* legacyWaitForHealthyServices(spawner, [...started.keys()], { + const healthCheck = legacyWaitForHealthyServices(spawner, [...started.keys()], { postgrest: postgrestGateway, edgeRuntime: edgeRuntimeGateway, images: started, - }).pipe( - Effect.result, - localKongCa !== undefined - ? Effect.provideService(FetchHttpClient.Fetch, legacyStorageGatewayFetch(localKongCa)) - : (effect) => effect, - ); + }).pipe(Effect.result); + const healthResult = yield* localGatewayHttpClient.use(localKongCa, healthCheck); if (Result.isFailure(healthResult)) { const error = healthResult.failure; if (flags.ignoreHealthCheck && legacyIsUnhealthyStartError(error)) { @@ -2040,7 +2038,7 @@ export const legacyStart = Effect.fn("legacy.start")(function* (flags: LegacySta } else { // No manual `legacyRollbackStart` here — the outer `Effect.onError` // below rolls back on this failure too. - return yield* Effect.fail(error); + return yield* error; } } diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 4ea6ca0b73..9d7b63ded4 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -1,14 +1,26 @@ import { generateKeyPairSync } from "node:crypto"; -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Fiber, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { + Effect, + Exit, + FileSystem, + Fiber, + Layer, + Option, + Path, + PlatformError, + ConfigProvider, + Schema, + Sink, + Stream, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; -import { vi } from "vitest"; +import { afterEach, vi } from "vitest"; import { mockAnalytics, @@ -32,6 +44,7 @@ import { LegacyNetworkIdFlag, LegacyYesFlag, } from "../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { LegacyPlatformApiFactory } from "../../auth/legacy-platform-api-factory.service.ts"; import { legacyServiceContainerIds, @@ -42,6 +55,7 @@ import { type LegacyDbSession, } from "../../shared/legacy-db-connection.service.ts"; import { legacyDockerRunLayer } from "../../shared/legacy-docker-run.layer.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../shared/legacy-local-gateway-http-client.ts"; import { LegacyEdgeRuntimeScriptError } from "../../shared/legacy-edge-runtime-script.errors.ts"; import { LegacyEdgeRuntimeScript, @@ -56,6 +70,64 @@ import { LEGACY_KONG_LOCAL_TLS_KEY, } from "./templates/kong-local-tls.ts"; +afterEach(() => vi.unstubAllEnvs()); + +const testPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + +function join(...paths: ReadonlyArray<string>): string { + return testPath.join(...paths); +} + +function fileExists(path: string): Effect.Effect<boolean, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }).pipe(Effect.provide(BunServices.layer)); +} + +function makeDirectory( + path: string, + options?: { readonly recursive?: boolean }, +): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }).pipe(Effect.provide(BunServices.layer)); +} + +function readDirectory( + path: string, +): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(path); + }).pipe(Effect.provide(BunServices.layer)); +} + +function readFile(path: string): Effect.Effect<string, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }).pipe(Effect.provide(BunServices.layer)); +} + +function removePath( + path: string, + options?: { readonly recursive?: boolean; readonly force?: boolean }, +): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }).pipe(Effect.provide(BunServices.layer)); +} + +function writeFile(path: string, data: string): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, data); + }).pipe(Effect.provide(BunServices.layer)); +} + /** * Counts real invocations of `legacyResolveLocalConfigValues` across this * whole file — every test transparently delegates to the real @@ -71,20 +143,21 @@ import { */ const legacyResolveLocalConfigValuesCalls = vi.hoisted(() => ({ count: 0 })); -vi.mock("../../shared/legacy-local-config-values.ts", async () => { - const actual = await vi.importActual<typeof import("../../shared/legacy-local-config-values.ts")>( - "../../shared/legacy-local-config-values.ts", - ); - return { - ...actual, - legacyResolveLocalConfigValues: ( - ...args: Parameters<typeof actual.legacyResolveLocalConfigValues> - ) => { - legacyResolveLocalConfigValuesCalls.count++; - return actual.legacyResolveLocalConfigValues(...args); - }, - }; -}); +vi.mock("../../shared/legacy-local-config-values.ts", () => + vi + .importActual<typeof import("../../shared/legacy-local-config-values.ts")>( + "../../shared/legacy-local-config-values.ts", + ) + .then((actual) => ({ + ...actual, + legacyResolveLocalConfigValues: ( + ...args: Parameters<typeof actual.legacyResolveLocalConfigValues> + ) => { + legacyResolveLocalConfigValuesCalls.count++; + return actual.legacyResolveLocalConfigValues(...args); + }, + })), +); const tempRoot = useLegacyTempWorkdir("supabase-start-int-"); @@ -96,10 +169,15 @@ function flags(overrides: Partial<LegacyStartFlags> = {}): LegacyStartFlags { }; } -function writeConfig(workdir: string, contents: string) { +function writeConfig( + workdir: string, + contents: string, +): Effect.Effect<void, PlatformError.PlatformError> { const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), contents); + return Effect.gen(function* () { + yield* makeDirectory(supabaseDir, { recursive: true }); + yield* writeFile(join(supabaseDir, "config.toml"), contents); + }); } interface SpawnRecord { @@ -147,6 +225,8 @@ function mockStartContainerCliSpawner( opts: { readonly failSpawn?: boolean; readonly onSecretCopy?: (containerPath: string, content: string) => void; + /** Paths removed after a container inspect, for stopped-stack validation fixtures. */ + readonly removeOnInspect?: ReadonlyArray<string>; } = {}, ) { const spawned: Array<SpawnRecord> = []; @@ -164,14 +244,12 @@ function mockStartContainerCliSpawner( spawned.push({ command: cmd, args, env }); if (opts.failSpawn === true) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } if (onSecretCopy !== undefined && args[0] === "cp" && args[1] === "-") { @@ -189,6 +267,12 @@ function mockStartContainerCliSpawner( } const result = route(args); + if (args[0] === "container" && args[1] === "inspect") { + const fs = yield* FileSystem.FileSystem; + for (const path of opts.removeOnInspect ?? []) { + yield* fs.remove(path, { force: true }); + } + } const stdoutBytes = (result.stdout ?? []).map((line) => encoder.encode(`${line}\n`)); const stderrBytes = (result.stderr ?? []).map((line) => encoder.encode(`${line}\n`)); return ChildProcessSpawner.makeHandle({ @@ -204,7 +288,7 @@ function mockStartContainerCliSpawner( getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); - }), + }).pipe(Effect.provide(BunServices.layer)), ), ); @@ -237,7 +321,7 @@ function isEdgeRuntimeCreate(args: ReadonlyArray<string>): boolean { * assertions here instead of shipping unreadable output to users. */ function fakeContainerId(name: string): string { - return [...name] + return Array.from(name) .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) .join("") .padEnd(64, "0") @@ -321,7 +405,12 @@ function freshVolumeRoute( /** Storage's `/storage/v1/bucket` GET (list)/POST (create) endpoints — every other request answers a bare 200, matching `alwaysReadyHttpClientLayer`'s permissiveness for the PostgREST/Edge Runtime readiness probes some scenarios also exercise. */ function mockStorageBucketHttpClient() { const createdBucketRequests: Array<string> = []; - const createdBucketBodies: Array<unknown> = []; + const bucketRequestSchema = Schema.Struct({ + file_size_limit: Schema.optional(Schema.Finite), + }); + const bucketRequestJsonSchema = Schema.fromJsonString(bucketRequestSchema); + type BucketRequest = Schema.Schema.Type<typeof bucketRequestSchema>; + const createdBucketBodies: Array<BucketRequest | undefined> = []; const layer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { @@ -339,18 +428,28 @@ function mockStorageBucketHttpClient() { if (request.method === "POST" && request.url.includes("/storage/v1/bucket")) { createdBucketRequests.push(request.url); if (request.body._tag === "Uint8Array") { - try { - createdBucketBodies.push(JSON.parse(new TextDecoder().decode(request.body.body))); - } catch { - createdBucketBodies.push(undefined); - } + return Schema.decodeEffect(bucketRequestJsonSchema)( + new TextDecoder().decode(request.body.body), + ).pipe( + Effect.option, + Effect.map((body) => { + createdBucketBodies.push(Option.getOrUndefined(body)); + return HttpClientResponse.fromWeb( + request, + new Response(Formatter.formatJson({ name: "avatars" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }), + ); } else { createdBucketBodies.push(undefined); } return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response(JSON.stringify({ name: "avatars" }), { + new Response(Formatter.formatJson({ name: "avatars" }), { status: 200, headers: { "content-type": "application/json" }, }), @@ -396,6 +495,8 @@ function fakeDbSession() { interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; + /** Explicit environment values for scenarios that must not mutate process-wide state. */ + readonly env?: Record<string, string>; readonly route?: (args: ReadonlyArray<string>) => RouteResult; /** Observes files decoded from the in-memory tar stream passed to `docker cp -`. */ readonly onSecretCopy?: (containerPath: string, content: string) => void; @@ -417,16 +518,23 @@ interface SetupOpts { readonly catalogStdout?: string; /** Fails the mocked catalog-export call with this message instead of succeeding. */ readonly catalogExportFailWith?: string; + /** Paths removed after a container inspect, for stopped-stack validation fixtures. */ + readonly removeOnInspect?: ReadonlyArray<string>; } function setup(opts: SetupOpts = {}) { const workdir = opts.workdir ?? tempRoot.current; - if (opts.skipConfig !== true) { - writeConfig( - workdir, - opts.configContents ?? `project_id = "${opts.configuredProjectId ?? "demo"}"\n`, - ); - } + const configProvider = + opts.env === undefined + ? ConfigProvider.fromEnv({ preserveEmptyStrings: true }) + : ConfigProvider.fromEnv({ env: opts.env, preserveEmptyStrings: true }); + const configSetup = + opts.skipConfig === true + ? Effect.void + : writeConfig( + workdir, + opts.configContents ?? `project_id = "${opts.configuredProjectId ?? "demo"}"\n`, + ); const out = mockOutput({ format: opts.format ?? "text" }); const telemetry = mockLegacyTelemetryStateTracked(); const analytics = mockAnalytics(); @@ -434,6 +542,7 @@ function setup(opts: SetupOpts = {}) { const child = mockStartContainerCliSpawner(opts.route ?? defaultRoute(), { failSpawn: opts.failSpawn, onSecretCopy: opts.onSecretCopy, + removeOnInspect: opts.removeOnInspect, }); const dbSession = fakeDbSession(); const edgeRunCalls: Array<LegacyEdgeRuntimeRunOpts> = []; @@ -455,12 +564,16 @@ function setup(opts: SetupOpts = {}) { const layer = Layer.mergeAll( BunServices.layer, + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), + makeLegacyViperEnvLayer(configProvider), + Layer.effectDiscard(configSetup), out.layer, cliConfig, telemetry.layer, analytics.layer, child.layer, opts.httpClientLayer ?? alwaysReadyHttpClientLayer, + legacyLocalGatewayHttpClientTestLayer(opts.httpClientLayer ?? alwaysReadyHttpClientLayer), // Only ever exercised by a fresh-volume scenario (`volume inspect` exiting // non-zero) — every other scenario's default "volume already exists" route // never reaches `legacyStartSetupLocalDatabase`/`legacySeedBucketsRun`, but @@ -670,7 +783,7 @@ describe("legacy start integration", () => { if (args[0] === "container" && args[1] === "inspect") { inspectCalls += 1; if (inspectCalls === 1) return { stdout: [HEALTHY_STATE] }; - return { stdout: [JSON.stringify({ Status: "exited", Running: false })] }; + return { stdout: [Formatter.formatJson({ Status: "exited", Running: false })] }; } return { exitCode: 0 }; }, @@ -679,7 +792,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotRunningError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbNotRunningError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe(Effect.provide(layer)); @@ -704,7 +817,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotReadyError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbNotReadyError"); } }).pipe(Effect.provide(layer)); }, @@ -728,7 +841,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStatusDbInspectError"); expect(serialized).toContain("permission denied"); } @@ -750,7 +863,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusListError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusListError"); } }).pipe(Effect.provide(layer)); }); @@ -818,7 +931,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyDbConfigLoadError"); expect(serialized).toContain( "failed to parse config: invalid storage.buckets.avatars.file_size_limit.", @@ -867,7 +980,8 @@ describe("legacy start integration", () => { child.spawned .filter((spawn) => spawn.args[0] === "stop") .map((spawn) => spawn.args[1]) - .sort(), + .filter((id): id is string => id !== undefined) + .sort((a, b) => a.localeCompare(b)), ).toEqual(["db-id", "kong-id"]); expect( child.spawned.some( @@ -914,7 +1028,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect( child.spawned.some( @@ -926,8 +1040,8 @@ describe("legacy start integration", () => { }); it.live("preserves a stopped Bitbucket database container", () => { - const previous = process.env["BITBUCKET_CLONE_DIR"]; - process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; + const previous = undefined; + vi.stubEnv("BITBUCKET_CLONE_DIR", "/opt/atlassian/pipelines/agent/build"); const { layer, child } = setup({ route: (args) => { if (args[0] === "container" && args[1] === "inspect") { @@ -941,7 +1055,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotRunningError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbNotRunningError"); } expect( child.spawned.some( @@ -956,8 +1070,8 @@ describe("legacy start integration", () => { Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["BITBUCKET_CLONE_DIR"]; - else process.env["BITBUCKET_CLONE_DIR"] = previous; + if (previous === undefined) vi.stubEnv("BITBUCKET_CLONE_DIR", undefined); + else vi.stubEnv("BITBUCKET_CLONE_DIR", previous); }), ), ); @@ -1001,7 +1115,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStatusDbNotRunningError"); expect(serialized).toContain("container is not running: created"); } @@ -1021,27 +1135,27 @@ describe("legacy start integration", () => { const workdir = tempRoot.current; const certPath = join(workdir, "supabase", "certs", "server.crt"); const keyPath = join(workdir, "supabase", "certs", "server.key"); - mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); - writeFileSync(certPath, "-----BEGIN CERTIFICATE-----"); - writeFileSync(keyPath, "-----BEGIN PRIVATE KEY-----"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\ncert_path = "certs/server.crt"\nkey_path = "certs/server.key"\n', route: (args) => { if (args[0] === "container" && args[1] === "inspect") { - if (existsSync(certPath)) rmSync(certPath); return { stdout: [STOPPED_STATE] }; } return { exitCode: 0 }; }, + removeOnInspect: [certPath], }); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "certs"), { recursive: true }); + yield* writeFile(certPath, "-----BEGIN CERTIFICATE-----"); + yield* writeFile(keyPath, "-----BEGIN PRIVATE KEY-----"); const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("failed to read TLS cert"); } @@ -1060,22 +1174,22 @@ describe("legacy start integration", () => { it.live("validates function bind mounts before removing a stopped stack", () => { const workdir = tempRoot.current; const entrypointPath = join(workdir, "supabase", "functions", "foo", "index.ts"); - mkdirSync(join(workdir, "supabase", "functions", "foo"), { recursive: true }); - writeFileSync(entrypointPath, "export {};\n"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[functions.foo]\nentrypoint = "./functions/foo/index.ts"\n', route: (args) => { if (args[0] === "container" && args[1] === "inspect") { - if (existsSync(entrypointPath)) rmSync(entrypointPath); return { stdout: [STOPPED_STATE] }; } return { exitCode: 0 }; }, + removeOnInspect: [entrypointPath], }); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "functions", "foo"), { recursive: true }); + yield* writeFile(entrypointPath, "export {};\n"); const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); expect( @@ -1152,8 +1266,6 @@ describe("legacy start integration", () => { "supabase_db_demo", ); const staleSecret = join(staleSecretDir, "stale-secret"); - mkdirSync(staleSecretDir, { recursive: true }); - writeFileSync(staleSecret, "stale"); const foreignWorkdir = join(workdir, "foreign"); const foreignSecretDir = join( foreignWorkdir, @@ -1163,8 +1275,6 @@ describe("legacy start integration", () => { "supabase_kong_demo", ); const foreignSecret = join(foreignSecretDir, "stale-secret"); - mkdirSync(foreignSecretDir, { recursive: true }); - writeFileSync(foreignSecret, "foreign"); const { layer, child } = setup({ route: (args) => { @@ -1187,13 +1297,19 @@ describe("legacy start integration", () => { }); return Effect.gen(function* () { + yield* makeDirectory(staleSecretDir, { recursive: true }); + yield* writeFile(staleSecret, "stale"); + yield* makeDirectory(foreignSecretDir, { recursive: true }); + yield* writeFile(foreignSecret, "foreign"); const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDockerRemoveAllNetworkPruneError"); + expect(Formatter.formatJson(exit.cause)).toContain( + "LegacyDockerRemoveAllNetworkPruneError", + ); } - expect(existsSync(staleSecret)).toBe(false); - expect(existsSync(foreignSecret)).toBe(true); + expect(yield* fileExists(staleSecret)).toBe(false); + expect(yield* fileExists(foreignSecret)).toBe(true); expect(createdContainerNames(child.spawned)).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -1213,7 +1329,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect( child.spawned.some( @@ -1274,7 +1390,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartWorkdirError"); expect(serialized).toContain( `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, @@ -1297,7 +1413,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyDockerLifecycleInspectError"); expect(serialized).toContain("permission denied"); } @@ -1313,7 +1429,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyDockerLifecycleInspectError"); expect(serialized).toContain("docker: command not found (podman also not found)"); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ @@ -1328,14 +1444,14 @@ describe("legacy start integration", () => { it.live("fails on a malformed config.toml", () => { const workdir = tempRoot.current; - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase"), { recursive: true }); + yield* writeFile(join(workdir, "supabase", "config.toml"), "not valid toml ====="); const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -1349,7 +1465,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain( "Invalid config for auth.jwt_secret. Must be at least 16 characters", @@ -1473,10 +1589,13 @@ describe("legacy start integration", () => { }); const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "test-kid" }; - writeFileSync(join(workdir, "supabase", "signing_keys.json"), JSON.stringify([jwk])); legacyResolveLocalConfigValuesCalls.count = 0; return Effect.gen(function* () { + yield* writeFile( + join(workdir, "supabase", "signing_keys.json"), + Formatter.formatJson([jwk]), + ); yield* legacyStart(flags()); expect(legacyResolveLocalConfigValuesCalls.count).toBe(1); }).pipe(Effect.provide(layer)); @@ -1505,7 +1624,7 @@ describe("legacy start integration", () => { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe( @@ -1524,16 +1643,16 @@ describe("legacy start integration", () => { configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\ncert_path = "certs/server.crt"\nkey_path = "certs/server.key"\n', }); - mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "certs", "server.crt"), - "-----BEGIN CERTIFICATE-----", - ); - writeFileSync( - join(workdir, "supabase", "certs", "server.key"), - "-----BEGIN PRIVATE KEY-----", - ); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "certs"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "certs", "server.crt"), + "-----BEGIN CERTIFICATE-----", + ); + yield* writeFile( + join(workdir, "supabase", "certs", "server.key"), + "-----BEGIN PRIVATE KEY-----", + ); yield* legacyStart(flags()); expect(createdContainerNames(child.spawned).some((name) => name.includes("_kong_"))).toBe( true, @@ -1546,16 +1665,16 @@ describe("legacy start integration", () => { configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\ncert_path = "certs/server.crt"\nkey_path = "certs/server.key"\n', }); - mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "certs", "server.key"), - "-----BEGIN PRIVATE KEY-----", - ); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "certs"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "certs", "server.key"), + "-----BEGIN PRIVATE KEY-----", + ); const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("failed to read TLS cert"); } @@ -1568,16 +1687,16 @@ describe("legacy start integration", () => { configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\ncert_path = "certs/server.crt"\nkey_path = "certs/server.key"\n', }); - mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "certs", "server.crt"), - "-----BEGIN CERTIFICATE-----", - ); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "certs"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "certs", "server.crt"), + "-----BEGIN CERTIFICATE-----", + ); const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("failed to read TLS key"); } @@ -1638,13 +1757,16 @@ content_path = "./supabase/templates/custom_notice.html" }); // `Config.Validate` (step 2, before this handler's own `buildKongEmailTemplateMounts` // ever runs) reads both content_path files from the project-root base. - mkdirSync(join(workdir, "supabase", "templates"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "templates", "confirmation.html"), "<html></html>"); - writeFileSync( - join(workdir, "supabase", "templates", "custom_notice.html"), - "<html></html>", - ); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "templates"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "templates", "confirmation.html"), + "<html></html>", + ); + yield* writeFile( + join(workdir, "supabase", "templates", "custom_notice.html"), + "<html></html>", + ); yield* legacyStart(flags()); const createdNames = createdContainerNames(child.spawned); expect(createdNames.some((name) => name.includes("_pooler_"))).toBe(true); @@ -1675,9 +1797,7 @@ content_path = "./supabase/templates/custom_notice.html" const { layer } = setup({ configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "0s"\n[auth.webauthn]\n', }); - return Effect.gen(function* () { - yield* legacyStart(flags()); - }).pipe(Effect.provide(layer)); + return legacyStart(flags()).pipe(Effect.provide(layer)); }, ); @@ -1696,7 +1816,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("failed to parse config"); } @@ -1719,7 +1839,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.file_size_limit"); } @@ -1734,14 +1854,14 @@ content_path = "./supabase/templates/custom_notice.html" // `storage.s3_protocol.enabled` is a plain bool decoded unconditionally at // config load — same class of gap as storage.file_size_limit above, // now fixed the same way (hoisted eager wrapConfigOverride in start.handler.ts). - const previous = process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"]; - process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"] = "not-a-bool"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", "not-a-bool"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.s3_protocol.enabled"); } @@ -1751,8 +1871,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"]; - else process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"] = previous; + vi.stubEnv("SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", undefined); + else vi.stubEnv("SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", previous); }), ), ); @@ -1767,14 +1887,14 @@ content_path = "./supabase/templates/custom_notice.html" // unconditionally at config load — same class of gap as // storage.s3_protocol.enabled above, now fixed the same way (hoisted eager // wrapConfigOverride in start.handler.ts). - const previous = process.env["SUPABASE_STORAGE_ANALYTICS_ENABLED"]; - process.env["SUPABASE_STORAGE_ANALYTICS_ENABLED"] = "not-a-bool"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_ENABLED", "not-a-bool"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.analytics.enabled"); } @@ -1783,8 +1903,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_STORAGE_ANALYTICS_ENABLED"]; - else process.env["SUPABASE_STORAGE_ANALYTICS_ENABLED"] = previous; + if (previous === undefined) + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_ENABLED", undefined); + else vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_ENABLED", previous); }), ), ); @@ -1798,14 +1919,14 @@ content_path = "./supabase/templates/custom_notice.html" // unconditionally at config load — same class of gap as // storage.s3_protocol.enabled above, now fixed the same way // (hoisted eager wrapConfigOverride in start.handler.ts). - const previous = process.env["SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES"]; - process.env["SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES"] = "not-a-uint"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", "not-a-uint"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.analytics.max_namespaces"); } @@ -1815,8 +1936,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES"]; - else process.env["SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES"] = previous; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", undefined); + else vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", previous); }), ), ); @@ -1828,14 +1949,14 @@ content_path = "./supabase/templates/custom_notice.html" () => { // Same gap as storage.analytics.max_namespaces above — `storage.analytics.max_tables` // decodes in the same config-load pass. - const previous = process.env["SUPABASE_STORAGE_ANALYTICS_MAX_TABLES"]; - process.env["SUPABASE_STORAGE_ANALYTICS_MAX_TABLES"] = "not-a-uint"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", "not-a-uint"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.analytics.max_tables"); } @@ -1845,8 +1966,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_STORAGE_ANALYTICS_MAX_TABLES"]; - else process.env["SUPABASE_STORAGE_ANALYTICS_MAX_TABLES"] = previous; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", undefined); + else vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_TABLES", previous); }), ), ); @@ -1858,14 +1979,14 @@ content_path = "./supabase/templates/custom_notice.html" () => { // Same gap as storage.analytics.max_namespaces above — `storage.analytics.max_catalogs` // decodes in the same config-load pass. - const previous = process.env["SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS"]; - process.env["SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS"] = "not-a-uint"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", "not-a-uint"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.analytics.max_catalogs"); } @@ -1875,8 +1996,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS"]; - else process.env["SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS"] = previous; + vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", undefined); + else vi.stubEnv("SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS", previous); }), ), ); @@ -1888,14 +2009,14 @@ content_path = "./supabase/templates/custom_notice.html" () => { // `storage.vector.max_buckets` is a plain uint decoded in the same config-load pass as // storage.analytics.* above, unconditionally. - const previous = process.env["SUPABASE_STORAGE_VECTOR_MAX_BUCKETS"]; - process.env["SUPABASE_STORAGE_VECTOR_MAX_BUCKETS"] = "not-a-uint"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", "not-a-uint"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.vector.max_buckets"); } @@ -1904,8 +2025,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_STORAGE_VECTOR_MAX_BUCKETS"]; - else process.env["SUPABASE_STORAGE_VECTOR_MAX_BUCKETS"] = previous; + if (previous === undefined) + vi.stubEnv("SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", undefined); + else vi.stubEnv("SUPABASE_STORAGE_VECTOR_MAX_BUCKETS", previous); }), ), ); @@ -1917,14 +2039,14 @@ content_path = "./supabase/templates/custom_notice.html" () => { // `storage.vector.max_indexes` is a plain uint decoded in the same config-load pass as // storage.vector.max_buckets above, unconditionally. - const previous = process.env["SUPABASE_STORAGE_VECTOR_MAX_INDEXES"]; - process.env["SUPABASE_STORAGE_VECTOR_MAX_INDEXES"] = "not-a-uint"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_VECTOR_MAX_INDEXES", "not-a-uint"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["storage"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for storage.vector.max_indexes"); } @@ -1933,8 +2055,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_STORAGE_VECTOR_MAX_INDEXES"]; - else process.env["SUPABASE_STORAGE_VECTOR_MAX_INDEXES"] = previous; + if (previous === undefined) + vi.stubEnv("SUPABASE_STORAGE_VECTOR_MAX_INDEXES", undefined); + else vi.stubEnv("SUPABASE_STORAGE_VECTOR_MAX_INDEXES", previous); }), ), ); @@ -1955,7 +2078,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.sms.max_frequency"); } @@ -1971,8 +2094,8 @@ content_path = "./supabase/templates/custom_notice.html" // load — `resolveGotrueRateLimit` only throws via an env var // override (a bad TOML value is caught by @supabase/config's own schema first), so this // models the override directly, same as the storage.s3_protocol.enabled test above. - const previous = process.env["SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS"]; - process.env["SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS"] = "not-a-uint"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", "not-a-uint"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n', }); @@ -1980,7 +2103,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.rate_limit"); } @@ -1990,8 +2113,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS"]; - else process.env["SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS"] = previous; + vi.stubEnv("SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", undefined); + else vi.stubEnv("SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS", previous); }), ), ); @@ -2004,8 +2127,8 @@ content_path = "./supabase/templates/custom_notice.html" // `auth.web3.*.enabled` are plain bools decoded unconditionally at // config load — same override-only-throw reasoning as the rate_limit // test above. - const previous = process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"]; - process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"] = "not-a-bool"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_WEB3_SOLANA_ENABLED", "not-a-bool"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n', }); @@ -2013,7 +2136,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.web3"); } @@ -2022,8 +2145,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"]; - else process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"] = previous; + if (previous === undefined) + vi.stubEnv("SUPABASE_AUTH_WEB3_SOLANA_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_WEB3_SOLANA_ENABLED", previous); }), ), ); @@ -2036,8 +2160,8 @@ content_path = "./supabase/templates/custom_notice.html" // `auth.oauth_server.enabled`/`allow_dynamic_registration` are plain bools decoded // unconditionally at config load — same // override-only-throw reasoning as the two tests above. - const previous = process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"]; - process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"] = "not-a-bool"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_OAUTH_SERVER_ENABLED", "not-a-bool"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n', }); @@ -2045,7 +2169,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.oauth_server"); } @@ -2054,8 +2178,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"]; - else process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"] = previous; + if (previous === undefined) + vi.stubEnv("SUPABASE_AUTH_OAUTH_SERVER_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_OAUTH_SERVER_ENABLED", previous); }), ), ); @@ -2068,8 +2193,8 @@ content_path = "./supabase/templates/custom_notice.html" // `auth.third_party.<provider>.enabled` are plain bools decoded unconditionally at // config load, same override-only-throw reasoning as the // web3/oauth_server tests above (review: PRRT_kwDOErm0O86WXFqj). - const previous = process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", "not-a-bool"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n', }); @@ -2077,7 +2202,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.third_party"); } @@ -2087,8 +2212,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; - else process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = previous; + vi.stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", previous); }), ), ); @@ -2112,7 +2237,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.passkey"); } @@ -2138,7 +2263,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.external"); } @@ -2163,7 +2288,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("'functions[foo]' has invalid keys: env"); } @@ -2180,14 +2305,14 @@ content_path = "./supabase/templates/custom_notice.html" // var override path (a plain string, unchecked by the schema) can reach // `legacyEnvOverrideEdgeRuntimePolicy`'s own throw, same reasoning as the GoTrue // override-only tests above. - const previous = process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "not-a-policy"; + const previous = undefined; + vi.stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", "not-a-policy"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["edge-runtime"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for edge_runtime.policy"); } @@ -2196,8 +2321,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - else process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", undefined); + else vi.stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", previous); }), ), ); @@ -2211,14 +2336,14 @@ content_path = "./supabase/templates/custom_notice.html" // TOML value is already rejected at config load — only the env var override path (a // string parsed by `envOverridePort`) can throw here, same reasoning as the policy test // above. - const previous = process.env["SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT"]; - process.env["SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT"] = "not-a-port"; + const previous = undefined; + vi.stubEnv("SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", "not-a-port"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags({ exclude: ["edge-runtime"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for edge_runtime.inspector_port"); } @@ -2228,8 +2353,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT"]; - else process.env["SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT"] = previous; + vi.stubEnv("SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", undefined); + else vi.stubEnv("SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", previous); }), ), ); @@ -2242,8 +2367,8 @@ content_path = "./supabase/templates/custom_notice.html" // `legacyResolveDockerDaemonHost` checks `DOCKER_HOST` before ever shelling out to // `docker context inspect`, so setting it directly is a reliable way to force the // npipe branch without needing a real Windows Docker Desktop context. - const previousDockerHost = process.env["DOCKER_HOST"]; - process.env["DOCKER_HOST"] = "npipe:////./pipe/docker_engine"; + const previousDockerHost = undefined; + vi.stubEnv("DOCKER_HOST", "npipe:////./pipe/docker_engine"); const { layer, out } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -2255,8 +2380,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previousDockerHost === undefined) delete process.env["DOCKER_HOST"]; - else process.env["DOCKER_HOST"] = previousDockerHost; + if (previousDockerHost === undefined) vi.stubEnv("DOCKER_HOST", undefined); + else vi.stubEnv("DOCKER_HOST", previousDockerHost); }), ), ); @@ -2311,8 +2436,8 @@ content_path = "./supabase/templates/custom_notice.html" // with no `[storage.image_transformation]` table, the env var is // never even looked up, so ImgProxy must stay off even though // storage itself is enabled. - const previous = process.env["SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED"]; - process.env["SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED"] = "true"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", "true"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -2324,9 +2449,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) { - delete process.env["SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED"]; + vi.stubEnv("SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", undefined); } else { - process.env["SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED"] = previous; + vi.stubEnv("SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", previous); } }), ), @@ -2416,18 +2541,18 @@ content_path = "./supabase/templates/custom_notice.html" route: freshVolumeRoute(defaultRoute()), catalogStdout: '{"snapshot":"ok"}', }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); return Effect.gen(function* () { + yield* writeFile(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); yield* legacyStart(flags({ exclude: ["edge-runtime"] })); expect(out.stderrText).not.toContain("failed to cache migrations catalog"); // Runs once, immediately AFTER the fresh-volume migrate+seed pipeline. expect(edgeRunCalls).toHaveLength(1); const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => + const catalogFiles = (yield* readDirectory(tempDir)).filter((name) => name.startsWith("catalog-local-migrations-"), ); expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); + expect(yield* readFile(join(tempDir, catalogFiles[0]!))).toBe('{"snapshot":"ok"}'); }).pipe(Effect.provide(layer)); }, ); @@ -2440,8 +2565,8 @@ content_path = "./supabase/templates/custom_notice.html" route: freshVolumeRoute(defaultRoute()), catalogExportFailWith: "edge-runtime script produced no output", }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); return Effect.gen(function* () { + yield* writeFile(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); const exit = yield* legacyStart(flags({ exclude: ["edge-runtime"] })).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(out.stderrText).toContain( @@ -2475,11 +2600,11 @@ content_path = "./supabase/templates/custom_notice.html" const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); // `loadProjectEnvironment`'s `envPath` is `<workdir>/supabase/.env` (`findProjectPaths`), // written after `setup()` so the `supabase/` dir (created by `writeConfig`) already exists. - writeFileSync( - join(workdir, "supabase", ".env"), - "SUPABASE_INTERNAL_IMAGE_REGISTRY=registry.example.com\n", - ); return Effect.gen(function* () { + yield* writeFile( + join(workdir, "supabase", ".env"), + "SUPABASE_INTERNAL_IMAGE_REGISTRY=registry.example.com\n", + ); yield* legacyStart(flags({ exclude: ["gotrue"] })); expect(dbSetupJobCalls(child.spawned)).toHaveLength(3); const authMigrateJob = dbSetupJobCalls(child.spawned).find((s) => @@ -2541,8 +2666,8 @@ content_path = "./supabase/templates/custom_notice.html" // `legacyCheckDbToml`'s pipeline does) must still fail eagerly, before any Docker work, // on an ordinary restart against an existing (non-fresh) volume: every `encrypted:` // value decrypts unconditionally regardless of volume state. - const previous = process.env["DOTENV_PRIVATE_KEY"]; - delete process.env["DOTENV_PRIVATE_KEY"]; + const previous = undefined; + vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); const encrypted = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; const { layer, child } = setup({ @@ -2552,7 +2677,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("failed to parse config: missing private key"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); @@ -2560,8 +2685,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; + if (previous === undefined) vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); + else vi.stubEnv("DOTENV_PRIVATE_KEY", previous); }), ), ); @@ -2585,7 +2710,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyDbConfigLoadError"); expect(serialized).toContain( "failed to parse config: invalid storage.buckets.avatars.file_size_limit.", @@ -2626,9 +2751,8 @@ content_path = "./supabase/templates/custom_notice.html" const { layer, workdir } = setup(); return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); - const content = readFileSync( + const content = yield* readFile( join(workdir, "supabase", ".branches", "_current_branch"), - "utf8", ); expect(content).toBe("main"); }).pipe(Effect.provide(layer)); @@ -2670,8 +2794,8 @@ content_path = "./supabase/templates/custom_notice.html" // reusing start's own already env-overridden config, so a SUPABASE_API_PORT // override that actually brought Kong up on a different port never reached // the bucket-seeding gateway's base URL. - const previous = process.env["SUPABASE_API_PORT"]; - process.env["SUPABASE_API_PORT"] = "65432"; + const previous = undefined; + vi.stubEnv("SUPABASE_API_PORT", "65432"); const http = mockStorageBucketHttpClient(); const { layer } = setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', @@ -2686,8 +2810,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_API_PORT"]; - else process.env["SUPABASE_API_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_API_PORT", undefined); + else vi.stubEnv("SUPABASE_API_PORT", previous); }), ), ); @@ -2701,8 +2825,8 @@ content_path = "./supabase/templates/custom_notice.html" // un-overridden config value, so a `SUPABASE_API_EXTERNAL_URL` override that // actually brought Kong/GoTrue up under a different external URL never reached // the bucket-seeding gateway's base URL. - const previous = process.env["SUPABASE_API_EXTERNAL_URL"]; - process.env["SUPABASE_API_EXTERNAL_URL"] = "http://override.example.com:9999"; + const previous = undefined; + vi.stubEnv("SUPABASE_API_EXTERNAL_URL", "http://override.example.com:9999"); const http = mockStorageBucketHttpClient(); const { layer } = setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', @@ -2717,8 +2841,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_API_EXTERNAL_URL"]; - else process.env["SUPABASE_API_EXTERNAL_URL"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_API_EXTERNAL_URL", undefined); + else vi.stubEnv("SUPABASE_API_EXTERNAL_URL", previous); }), ), ); @@ -2732,8 +2856,8 @@ content_path = "./supabase/templates/custom_notice.html" // raw, un-overridden config value, so `legacySeedBucketsRun`'s per-bucket default // (for a bucket with no explicit `file_size_limit` of its own) never reflected an // env/dotenv-only `SUPABASE_STORAGE_FILE_SIZE_LIMIT` override. - const previous = process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"]; - process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"] = "10MiB"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_FILE_SIZE_LIMIT", "10MiB"); const http = mockStorageBucketHttpClient(); const { layer } = setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', @@ -2743,15 +2867,13 @@ content_path = "./supabase/templates/custom_notice.html" return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); expect(http.createdBucketBodies).toHaveLength(1); - expect( - (http.createdBucketBodies[0] as { file_size_limit?: number })?.file_size_limit, - ).toBe(10 * 1024 * 1024); + expect(http.createdBucketBodies[0]?.file_size_limit).toBe(10 * 1024 * 1024); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"]; - else process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_STORAGE_FILE_SIZE_LIMIT", undefined); + else vi.stubEnv("SUPABASE_STORAGE_FILE_SIZE_LIMIT", previous); }), ), ); @@ -2811,12 +2933,12 @@ content_path = "./supabase/templates/custom_notice.html" const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); expect(envFilePath?.startsWith(stagingRoot)).toBe(true); try { - expect(existsSync(envFilePath ?? "")).toBe(true); + expect(yield* fileExists(envFilePath ?? "")).toBe(true); // `<stagingRoot>/<container>/env/docker.env` → the staging dir is two levels up. const containerStagingDir = join(envFilePath ?? "", "..", ".."); - expect(existsSync(join(containerStagingDir, "main"))).toBe(false); + expect(yield* fileExists(join(containerStagingDir, "main"))).toBe(false); } finally { - rmSync(stagingRoot, { recursive: true, force: true }); + yield* removePath(stagingRoot, { recursive: true, force: true }); } }).pipe(Effect.provide(layer)); }, @@ -2830,20 +2952,24 @@ content_path = "./supabase/templates/custom_notice.html" // `start.handler.ts`'s "studio" case doc comment) — the skip line still // logs via that path alone here, since Edge Runtime itself never runs to log it too. const workdir = tempRoot.current; - mkdirSync(join(workdir, "supabase", "functions", "foo"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "functions", "foo", "index.ts"), "export {};\n"); const { layer, out } = setup({ configContents: 'project_id = "demo"\n[functions.foo]\nenabled = false\n', }); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "functions", "foo"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "functions", "foo", "index.ts"), + "export {};\n", + ); yield* legacyStart(flags({ exclude: ["edge-runtime"] })); expect(out.stderrText).toContain("Skipped serving Function: foo"); }).pipe( Effect.provide(layer), Effect.ensuring( - Effect.sync(() => { - rmSync(join(workdir, "supabase", "functions"), { recursive: true, force: true }); - }), + removePath(join(workdir, "supabase", "functions"), { + recursive: true, + force: true, + }).pipe(Effect.ignore), ), ); }, @@ -2861,20 +2987,22 @@ content_path = "./supabase/templates/custom_notice.html" // UNRELATED ancestor project's `supabase/functions` from silently // winning for this workdir, mirroring that same `search: false`. const ancestorRoot = tempRoot.current; - mkdirSync(join(ancestorRoot, "supabase", "functions", "foo"), { recursive: true }); - writeFileSync( - join(ancestorRoot, "supabase", "functions", "foo", "index.ts"), - "export {};\n", - ); - writeFileSync( - join(ancestorRoot, "supabase", "config.toml"), - 'project_id = "ancestor"\n[functions.foo]\nenabled = true\n', - ); const workdir = join(ancestorRoot, "nested", "workdir"); - mkdirSync(workdir, { recursive: true }); const { layer, out, child } = setup({ workdir, skipConfig: true }); return Effect.gen(function* () { + yield* makeDirectory(join(ancestorRoot, "supabase", "functions", "foo"), { + recursive: true, + }); + yield* writeFile( + join(ancestorRoot, "supabase", "functions", "foo", "index.ts"), + "export {};\n", + ); + yield* writeFile( + join(ancestorRoot, "supabase", "config.toml"), + 'project_id = "ancestor"\n[functions.foo]\nenabled = true\n', + ); + yield* makeDirectory(workdir, { recursive: true }); yield* legacyStart(flags()); const runArgs = edgeRuntimeRunCalls(child.spawned)[0]?.args ?? []; const bindValues = runArgs.flatMap((arg, i) => (runArgs[i - 1] === "-v" ? [arg] : [])); @@ -2883,9 +3011,10 @@ content_path = "./supabase/templates/custom_notice.html" }).pipe( Effect.provide(layer), Effect.ensuring( - Effect.sync(() => { - rmSync(join(ancestorRoot, "supabase", "functions"), { recursive: true, force: true }); - }), + removePath(join(ancestorRoot, "supabase", "functions"), { + recursive: true, + force: true, + }).pipe(Effect.ignore), ), ); }, @@ -2966,7 +3095,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyImagePrepullError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyImagePrepullError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); expect(rollbackWasAttempted(child.spawned)).toBe(false); @@ -3016,7 +3145,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags({ ignoreHealthCheck: true }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyImagePrepullError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyImagePrepullError"); } expect(out.stderrText).not.toContain("Started"); expect(out.stderrText).not.toContain("Local dev security notice"); @@ -3169,7 +3298,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyNetworkCreateError"); expect(serialized).toContain("failed to create docker network"); } @@ -3194,7 +3323,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyContainerCreateError"); expect(serialized).toContain("failed to create docker container"); } @@ -3220,7 +3349,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyContainerStartError"); expect(serialized).toContain("port is already allocated"); expect(serialized).toContain( @@ -3248,7 +3377,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for auth.email.max_frequency"); } @@ -3268,8 +3397,8 @@ content_path = "./supabase/templates/custom_notice.html" // case above, this override is read before any network/container work starts, so // there is nothing yet for rollback to prune — the point of this test is solely that // the typed LegacyStartInvalidConfigError surfaces instead of a defect. - const previous = process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"]; - process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"] = "abc"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_EMAIL_OTP_LENGTH", "abc"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n', }); @@ -3277,7 +3406,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned).toHaveLength(0); @@ -3285,8 +3414,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"]; - else process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_EMAIL_OTP_LENGTH", undefined); + else vi.stubEnv("SUPABASE_AUTH_EMAIL_OTP_LENGTH", previous); }), ), ); @@ -3305,8 +3434,8 @@ content_path = "./supabase/templates/custom_notice.html" // network/container work starts, so there is nothing yet for rollback to prune — the // point of this test is solely that the typed LegacyStartInvalidConfigError surfaces // instead of a defect. - const previous = process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "bad"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "bad"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n', }); @@ -3314,7 +3443,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned).toHaveLength(0); @@ -3322,8 +3451,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - else process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", undefined); + else vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", previous); }), ), ); @@ -3353,7 +3482,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyHealthCheckTimeoutError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyHealthCheckTimeoutError"); } expect(rollbackWasAttempted(child.spawned)).toBe(true); // Postgres's own health wait fails before any other service is ever created. @@ -3448,7 +3577,7 @@ content_path = "./supabase/templates/custom_notice.html" ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyHealthCheckTimeoutError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyHealthCheckTimeoutError"); } expect(out.stderrText).not.toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(true); @@ -3599,7 +3728,7 @@ content_path = "./supabase/templates/custom_notice.html" ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStorageGatewayStatusError"); // The seed error REPLACES the original health-check timeout entirely. expect(serialized).not.toContain("LegacyHealthCheckTimeoutError"); @@ -3693,31 +3822,21 @@ content_path = "./supabase/templates/custom_notice.html" // `--network-id` falls back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var // ONLY when the flag was never passed (review: PRRT_kwDOErm0O86VlqIL) — see // `start.handler.ts`'s own comment on this resolution for the full precedence. - const previous = process.env["SUPABASE_NETWORK_ID"]; - process.env["SUPABASE_NETWORK_ID"] = "env-net"; - const { layer, child } = setup(); + const { layer, child } = setup({ env: { SUPABASE_NETWORK_ID: "env-net" } }); return Effect.gen(function* () { yield* legacyStart(flags()); const networkCreate = child.spawned.find( (s) => s.args[0] === "network" && s.args[1] === "create", ); expect(networkCreate?.args.at(-1)).toBe("env-net"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_NETWORK_ID"]; - else process.env["SUPABASE_NETWORK_ID"] = previous; - }), - ), - ); + }).pipe(Effect.provide(layer)); }); }); describe("SUPABASE_API_PORT override", () => { it.live("publishes Kong on the env-overridden API port, not config.api.port", () => { - const previous = process.env["SUPABASE_API_PORT"]; - process.env["SUPABASE_API_PORT"] = "61234"; + const previous = undefined; + vi.stubEnv("SUPABASE_API_PORT", "61234"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -3730,8 +3849,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_API_PORT"]; - else process.env["SUPABASE_API_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_API_PORT", undefined); + else vi.stubEnv("SUPABASE_API_PORT", previous); }), ), ); @@ -3743,9 +3862,12 @@ content_path = "./supabase/templates/custom_notice.html" "threads a linked project's supabase/.temp/storage-migration pin into DB_MIGRATIONS_FREEZE_AT", () => { const { layer, workdir, child } = setup(); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", ".temp", "storage-migration"), "20240102030405\n"); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", ".temp"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", ".temp", "storage-migration"), + "20240102030405\n", + ); yield* legacyStart(flags()); const storageCreate = child.spawned.find( (s) => @@ -3774,9 +3896,9 @@ content_path = "./supabase/templates/custom_notice.html" "resolves a supabase/.temp/storage-version pin into the pulled/created storage image tag", () => { const { layer, workdir, child } = setup(); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", ".temp", "storage-version"), "1.2.3\n"); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", ".temp"), { recursive: true }); + yield* writeFile(join(workdir, "supabase", ".temp", "storage-version"), "1.2.3\n"); yield* legacyStart(flags()); const storageImageInspect = child.spawned.find( (s) => @@ -3912,10 +4034,10 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL / SUPABASE_LOCAL_SMTP_SENDER_NAME overrides", () => { it.live("honors env overrides for the Mailpit fallback's admin email and sender name", () => { - const previousAdminEmail = process.env["SUPABASE_LOCAL_SMTP_ADMIN_EMAIL"]; - const previousSenderName = process.env["SUPABASE_LOCAL_SMTP_SENDER_NAME"]; - process.env["SUPABASE_LOCAL_SMTP_ADMIN_EMAIL"] = "override-admin@example.com"; - process.env["SUPABASE_LOCAL_SMTP_SENDER_NAME"] = "Override Sender"; + const previousAdminEmail = undefined; + const previousSenderName = undefined; + vi.stubEnv("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", "override-admin@example.com"); + vi.stubEnv("SUPABASE_LOCAL_SMTP_SENDER_NAME", "Override Sender"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -3929,14 +4051,14 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousAdminEmail === undefined) { - delete process.env["SUPABASE_LOCAL_SMTP_ADMIN_EMAIL"]; + vi.stubEnv("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", undefined); } else { - process.env["SUPABASE_LOCAL_SMTP_ADMIN_EMAIL"] = previousAdminEmail; + vi.stubEnv("SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", previousAdminEmail); } if (previousSenderName === undefined) { - delete process.env["SUPABASE_LOCAL_SMTP_SENDER_NAME"]; + vi.stubEnv("SUPABASE_LOCAL_SMTP_SENDER_NAME", undefined); } else { - process.env["SUPABASE_LOCAL_SMTP_SENDER_NAME"] = previousSenderName; + vi.stubEnv("SUPABASE_LOCAL_SMTP_SENDER_NAME", previousSenderName); } }), ), @@ -3948,22 +4070,22 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_LOCAL_SMTP_SMTP_PORT", () => { - const previous = process.env["SUPABASE_LOCAL_SMTP_SMTP_PORT"]; - process.env["SUPABASE_LOCAL_SMTP_SMTP_PORT"] = "not-a-port"; + const previous = undefined; + vi.stubEnv("SUPABASE_LOCAL_SMTP_SMTP_PORT", "not-a-port"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_LOCAL_SMTP_SMTP_PORT"]; - else process.env["SUPABASE_LOCAL_SMTP_SMTP_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_LOCAL_SMTP_SMTP_PORT", undefined); + else vi.stubEnv("SUPABASE_LOCAL_SMTP_SMTP_PORT", previous); }), ), ); @@ -3981,12 +4103,11 @@ content_path = "./supabase/templates/custom_notice.html" "SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", "SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", ] as const; - const previous = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "override-account-sid"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = - "override-message-service-sid"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "override-auth-token"; + const previous: Partial<Record<(typeof envKeys)[number], string | undefined>> = {}; + vi.stubEnv("SUPABASE_AUTH_SMS_TWILIO_ENABLED", "true"); + vi.stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", "override-account-sid"); + vi.stubEnv("SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", "override-message-service-sid"); + vi.stubEnv("SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", "override-auth-token"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.sms.twilio]\nenabled = false\n', }); @@ -4007,8 +4128,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.sync(() => { for (const key of envKeys) { const value = previous[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; + if (value === undefined) vi.stubEnv(key, undefined); + else vi.stubEnv(key, value); } }), ), @@ -4019,10 +4140,10 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors SUPABASE_AUTH_SMS_ENABLE_SIGNUP and SUPABASE_AUTH_SMS_MAX_FREQUENCY in GoTrue's env", () => { - const previousEnableSignup = process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - const previousMaxFrequency = process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; + const previousEnableSignup = undefined; + const previousMaxFrequency = undefined; + vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "true"); + vi.stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", "10s"); // A complete, enabled provider is required, or SMS validation downgrades // enable_signup to false regardless of the override — see the "disables phone login" // test below for that behavior itself. @@ -4042,14 +4163,14 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousEnableSignup === undefined) { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", undefined); } else { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = previousEnableSignup; + vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", previousEnableSignup); } if (previousMaxFrequency === undefined) { - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + vi.stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", undefined); } else { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = previousMaxFrequency; + vi.stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", previousMaxFrequency); } }), ), @@ -4063,8 +4184,8 @@ content_path = "./supabase/templates/custom_notice.html" // SMS validation downgrades `enable_signup` to `false` (plus a stderr warning) — // reached only when every named provider is disabled — before `legacyBuildGotrueEnv` // ever reads it. - const previous = process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "true"); const { layer, child, out } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4079,8 +4200,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - else process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", undefined); + else vi.stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", previous); }), ), ); @@ -4092,10 +4213,10 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP and SUPABASE_AUTH_EMAIL_OTP_LENGTH in GoTrue's env", () => { - const previousEnableSignup = process.env["SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP"]; - const previousOtpLength = process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"]; - process.env["SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP"] = "false"; - process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"] = "8"; + const previousEnableSignup = undefined; + const previousOtpLength = undefined; + vi.stubEnv("SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", "false"); + vi.stubEnv("SUPABASE_AUTH_EMAIL_OTP_LENGTH", "8"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4109,14 +4230,14 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousEnableSignup === undefined) { - delete process.env["SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP"]; + vi.stubEnv("SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", undefined); } else { - process.env["SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP"] = previousEnableSignup; + vi.stubEnv("SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", previousEnableSignup); } if (previousOtpLength === undefined) { - delete process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"]; + vi.stubEnv("SUPABASE_AUTH_EMAIL_OTP_LENGTH", undefined); } else { - process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"] = previousOtpLength; + vi.stubEnv("SUPABASE_AUTH_EMAIL_OTP_LENGTH", previousOtpLength); } }), ), @@ -4127,15 +4248,15 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors SUPABASE_AUTH_EMAIL_TEMPLATE_<NAME>_SUBJECT in GoTrue's mailer subject env", () => { - const previous = process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = "Override subject"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT", "Override subject"); const { layer, workdir, child } = setup({ configContents: 'project_id = "demo"\n[auth.email.template.confirmation]\ncontent_path = "./templates/confirmation.html"\n', }); - mkdirSync(join(workdir, "templates"), { recursive: true }); - writeFileSync(join(workdir, "templates", "confirmation.html"), "<html></html>"); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "templates"), { recursive: true }); + yield* writeFile(join(workdir, "templates", "confirmation.html"), "<html></html>"); yield* legacyStart(flags()); const gotrueCreate = child.spawned.find( (s) => s.args[0] === "create" && containerNameFromCreateArgs(s.args).includes("_auth_"), @@ -4146,9 +4267,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; + vi.stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT", undefined); } else { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = previous; + vi.stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT", previous); } }), ), @@ -4162,8 +4283,8 @@ content_path = "./supabase/templates/custom_notice.html" // The env override folds into the email template's content field before // validation runs, so it is rejected exactly like a raw TOML `content` key with // no `content_path` — before start touches Docker at all. - const previous = process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT"]; - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT"] = "<html>Hi</html>"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT", "<html>Hi</html>"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.email.template.confirmation]\n', }); @@ -4171,7 +4292,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain( "Invalid config for auth.email.template.confirmation.content: please use content_path instead", @@ -4183,9 +4304,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT"]; + vi.stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT", undefined); } else { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT"] = previous; + vi.stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_CONTENT", previous); } }), ), @@ -4196,8 +4317,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_DB_PORT override", () => { it.live("publishes Postgres on the env-overridden DB port, not config.db.port", () => { - const previous = process.env["SUPABASE_DB_PORT"]; - process.env["SUPABASE_DB_PORT"] = "54329"; + const previous = undefined; + vi.stubEnv("SUPABASE_DB_PORT", "54329"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4210,8 +4331,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_PORT"]; - else process.env["SUPABASE_DB_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_DB_PORT", undefined); + else vi.stubEnv("SUPABASE_DB_PORT", previous); }), ), ); @@ -4220,8 +4341,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_DB_SETTINGS_* env overrides", () => { it.live("honors SUPABASE_DB_SETTINGS_SHARED_BUFFERS in the rendered postgresql.conf", () => { - const previous = process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"]; - process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = "256MB"; + const previous = undefined; + vi.stubEnv("SUPABASE_DB_SETTINGS_SHARED_BUFFERS", "256MB"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4233,8 +4354,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"]; - else process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = previous; + if (previous === undefined) + vi.stubEnv("SUPABASE_DB_SETTINGS_SHARED_BUFFERS", undefined); + else vi.stubEnv("SUPABASE_DB_SETTINGS_SHARED_BUFFERS", previous); }), ), ); @@ -4245,10 +4367,10 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors SUPABASE_STORAGE_S3_PROTOCOL_ENABLED and SUPABASE_STORAGE_VECTOR_ENABLED", () => { - const previousS3 = process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"]; - const previousVector = process.env["SUPABASE_STORAGE_VECTOR_ENABLED"]; - process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"] = "false"; - process.env["SUPABASE_STORAGE_VECTOR_ENABLED"] = "false"; + const previousS3 = undefined; + const previousVector = undefined; + vi.stubEnv("SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", "false"); + vi.stubEnv("SUPABASE_STORAGE_VECTOR_ENABLED", "false"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4262,11 +4384,11 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousS3 === undefined) - delete process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"]; - else process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"] = previousS3; + vi.stubEnv("SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", undefined); + else vi.stubEnv("SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", previousS3); if (previousVector === undefined) - delete process.env["SUPABASE_STORAGE_VECTOR_ENABLED"]; - else process.env["SUPABASE_STORAGE_VECTOR_ENABLED"] = previousVector; + vi.stubEnv("SUPABASE_STORAGE_VECTOR_ENABLED", undefined); + else vi.stubEnv("SUPABASE_STORAGE_VECTOR_ENABLED", previousVector); }), ), ); @@ -4276,14 +4398,14 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_ANALYTICS_* env overrides", () => { it.live("honors SUPABASE_ANALYTICS_BACKEND/_GCP_* for both Logflare and Studio", () => { - const previousBackend = process.env["SUPABASE_ANALYTICS_BACKEND"]; - const previousProjectId = process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - const previousProjectNumber = process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - const previousJwtPath = process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-gcp-project"; - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "987654321"; - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp-key.json"; + const previousBackend = undefined; + const previousProjectId = undefined; + const previousProjectNumber = undefined; + const previousJwtPath = undefined; + vi.stubEnv("SUPABASE_ANALYTICS_BACKEND", "bigquery"); + vi.stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", "env-gcp-project"); + vi.stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", "987654321"); + vi.stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", "gcp-key.json"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4301,17 +4423,17 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previousBackend === undefined) delete process.env["SUPABASE_ANALYTICS_BACKEND"]; - else process.env["SUPABASE_ANALYTICS_BACKEND"] = previousBackend; + if (previousBackend === undefined) vi.stubEnv("SUPABASE_ANALYTICS_BACKEND", undefined); + else vi.stubEnv("SUPABASE_ANALYTICS_BACKEND", previousBackend); if (previousProjectId === undefined) - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - else process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = previousProjectId; + vi.stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", undefined); + else vi.stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", previousProjectId); if (previousProjectNumber === undefined) - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - else process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = previousProjectNumber; + vi.stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", undefined); + else vi.stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", previousProjectNumber); if (previousJwtPath === undefined) - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - else process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = previousJwtPath; + vi.stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", undefined); + else vi.stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", previousJwtPath); }), ), ); @@ -4320,8 +4442,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("auth.* env overrides reach GoTrue's container", () => { it.live("honors SUPABASE_AUTH_ENABLE_SIGNUP for GOTRUE_DISABLE_SIGNUP", () => { - const previous = process.env["SUPABASE_AUTH_ENABLE_SIGNUP"]; - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "false"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_ENABLE_SIGNUP", "false"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4333,8 +4455,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLE_SIGNUP"]; - else process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_ENABLE_SIGNUP", undefined); + else vi.stubEnv("SUPABASE_AUTH_ENABLE_SIGNUP", previous); }), ), ); @@ -4343,8 +4465,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { it.live("resolves the Deno 1 edge-runtime image tag, not the Deno 2 default", () => { - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; + const previous = undefined; + vi.stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", "1"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4361,8 +4483,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - else process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", undefined); + else vi.stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", previous); }), ), ); @@ -4373,10 +4495,10 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors SUPABASE_REALTIME_IP_VERSION/_MAX_HEADER_LENGTH for both the long-running container and the PG15+ setup job", () => { - const previousIpVersion = process.env["SUPABASE_REALTIME_IP_VERSION"]; - const previousMaxHeaderLength = process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv6"; - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "8192"; + const previousIpVersion = undefined; + const previousMaxHeaderLength = undefined; + vi.stubEnv("SUPABASE_REALTIME_IP_VERSION", "IPv6"); + vi.stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", "8192"); const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); @@ -4400,11 +4522,11 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousIpVersion === undefined) - delete process.env["SUPABASE_REALTIME_IP_VERSION"]; - else process.env["SUPABASE_REALTIME_IP_VERSION"] = previousIpVersion; + vi.stubEnv("SUPABASE_REALTIME_IP_VERSION", undefined); + else vi.stubEnv("SUPABASE_REALTIME_IP_VERSION", previousIpVersion); if (previousMaxHeaderLength === undefined) - delete process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; - else process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = previousMaxHeaderLength; + vi.stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", undefined); + else vi.stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", previousMaxHeaderLength); }), ), ); @@ -4414,22 +4536,22 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_REALTIME_IP_VERSION", () => { - const previous = process.env["SUPABASE_REALTIME_IP_VERSION"]; - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; + const previous = undefined; + vi.stubEnv("SUPABASE_REALTIME_IP_VERSION", "IPv5"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_REALTIME_IP_VERSION"]; - else process.env["SUPABASE_REALTIME_IP_VERSION"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_REALTIME_IP_VERSION", undefined); + else vi.stubEnv("SUPABASE_REALTIME_IP_VERSION", previous); }), ), ); @@ -4459,8 +4581,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors the override for both Storage's container and the fresh-volume migrate job", () => { - const previous = process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"]; - process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"] = "5MiB"; + const previous = undefined; + vi.stubEnv("SUPABASE_STORAGE_FILE_SIZE_LIMIT", "5MiB"); const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* legacyStart(flags({ exclude: ["edge-runtime"] })); @@ -4479,8 +4601,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"]; - else process.env["SUPABASE_STORAGE_FILE_SIZE_LIMIT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_STORAGE_FILE_SIZE_LIMIT", undefined); + else vi.stubEnv("SUPABASE_STORAGE_FILE_SIZE_LIMIT", previous); }), ), ); @@ -4492,8 +4614,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "selects the OrioleDB Postgres image and enables the container's S3 env when set only via env", () => { - const previous = process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"]; - process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"] = "16.0.0.1"; + const previous = undefined; + vi.stubEnv("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", "16.0.0.1"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4514,8 +4636,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previous === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"]; - else process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"] = previous; + vi.stubEnv("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", undefined); + else vi.stubEnv("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", previous); }), ), ); @@ -4523,16 +4645,16 @@ content_path = "./supabase/templates/custom_notice.html" ); it.live("honors SUPABASE_EXPERIMENTAL_S3_HOST/_REGION/_ACCESS_KEY/_SECRET_KEY", () => { - const previousVersion = process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"]; - const previousHost = process.env["SUPABASE_EXPERIMENTAL_S3_HOST"]; - const previousRegion = process.env["SUPABASE_EXPERIMENTAL_S3_REGION"]; - const previousAccessKey = process.env["SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY"]; - const previousSecretKey = process.env["SUPABASE_EXPERIMENTAL_S3_SECRET_KEY"]; - process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"] = "16.0.0.1"; - process.env["SUPABASE_EXPERIMENTAL_S3_HOST"] = "env-s3-host"; - process.env["SUPABASE_EXPERIMENTAL_S3_REGION"] = "env-s3-region"; - process.env["SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY"] = "env-s3-access-key"; - process.env["SUPABASE_EXPERIMENTAL_S3_SECRET_KEY"] = "env-s3-secret-key"; + const previousVersion = undefined; + const previousHost = undefined; + const previousRegion = undefined; + const previousAccessKey = undefined; + const previousSecretKey = undefined; + vi.stubEnv("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", "16.0.0.1"); + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_HOST", "env-s3-host"); + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_REGION", "env-s3-region"); + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", "env-s3-access-key"); + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", "env-s3-secret-key"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4548,18 +4670,19 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousVersion === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"]; - else process.env["SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION"] = previousVersion; - if (previousHost === undefined) delete process.env["SUPABASE_EXPERIMENTAL_S3_HOST"]; - else process.env["SUPABASE_EXPERIMENTAL_S3_HOST"] = previousHost; - if (previousRegion === undefined) delete process.env["SUPABASE_EXPERIMENTAL_S3_REGION"]; - else process.env["SUPABASE_EXPERIMENTAL_S3_REGION"] = previousRegion; + vi.stubEnv("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", undefined); + else vi.stubEnv("SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION", previousVersion); + if (previousHost === undefined) vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_HOST", undefined); + else vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_HOST", previousHost); + if (previousRegion === undefined) + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_REGION", undefined); + else vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_REGION", previousRegion); if (previousAccessKey === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY"]; - else process.env["SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY"] = previousAccessKey; + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", undefined); + else vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_ACCESS_KEY", previousAccessKey); if (previousSecretKey === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_S3_SECRET_KEY"]; - else process.env["SUPABASE_EXPERIMENTAL_S3_SECRET_KEY"] = previousSecretKey; + vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", undefined); + else vi.stubEnv("SUPABASE_EXPERIMENTAL_S3_SECRET_KEY", previousSecretKey); }), ), ); @@ -4612,44 +4735,34 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "reads the env-overridden cert/key paths for Kong, not the (absent) TOML fields", () => { - const previousCert = process.env["SUPABASE_API_TLS_CERT_PATH"]; - const previousKey = process.env["SUPABASE_API_TLS_KEY_PATH"]; const copied = new Map<string, string>(); const { layer, workdir, child } = setup({ configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\n', + env: { + SUPABASE_API_TLS_CERT_PATH: "certs/env-server.crt", + SUPABASE_API_TLS_KEY_PATH: "certs/env-server.key", + }, onSecretCopy: (containerPath, content) => { copied.set(containerPath, content); }, }); - mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "certs", "env-server.crt"), - "-----BEGIN CERTIFICATE-----env-cert", - ); - writeFileSync( - join(workdir, "supabase", "certs", "env-server.key"), - "-----BEGIN PRIVATE KEY-----env-key", - ); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "certs/env-server.crt"; - process.env["SUPABASE_API_TLS_KEY_PATH"] = "certs/env-server.key"; return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "certs"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "certs", "env-server.crt"), + "-----BEGIN CERTIFICATE-----env-cert", + ); + yield* writeFile( + join(workdir, "supabase", "certs", "env-server.key"), + "-----BEGIN PRIVATE KEY-----env-key", + ); yield* legacyStart(flags()); expect(child.spawned.some((s) => s.args[0] === "create")).toBe(true); expect(copied.get("/home/kong/localhost.crt")).toBe( "-----BEGIN CERTIFICATE-----env-cert", ); expect(copied.get("/home/kong/localhost.key")).toBe("-----BEGIN PRIVATE KEY-----env-key"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previousCert === undefined) delete process.env["SUPABASE_API_TLS_CERT_PATH"]; - else process.env["SUPABASE_API_TLS_CERT_PATH"] = previousCert; - if (previousKey === undefined) delete process.env["SUPABASE_API_TLS_KEY_PATH"]; - else process.env["SUPABASE_API_TLS_KEY_PATH"] = previousKey; - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); }); @@ -4661,71 +4774,52 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "skips the configured cert/key read for Kong when API is disabled only via env override", () => { - const previous = process.env["SUPABASE_API_ENABLED"]; - process.env["SUPABASE_API_ENABLED"] = "false"; const copied = new Map<string, string>(); const { layer, workdir, child } = setup({ configContents: 'project_id = "demo"\n[api.tls]\nenabled = true\n', + env: { SUPABASE_API_ENABLED: "false" }, onSecretCopy: (containerPath, content) => { copied.set(containerPath, content); }, }); - mkdirSync(join(workdir, "supabase", "certs"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "certs", "server.crt"), - "-----BEGIN CERTIFICATE-----custom-cert", - ); - writeFileSync( - join(workdir, "supabase", "certs", "server.key"), - "-----BEGIN PRIVATE KEY-----custom-key", - ); return Effect.gen(function* () { + yield* makeDirectory(join(workdir, "supabase", "certs"), { recursive: true }); + yield* writeFile( + join(workdir, "supabase", "certs", "server.crt"), + "-----BEGIN CERTIFICATE-----custom-cert", + ); + yield* writeFile( + join(workdir, "supabase", "certs", "server.key"), + "-----BEGIN PRIVATE KEY-----custom-key", + ); yield* legacyStart(flags()); expect(child.spawned.some((s) => s.args[0] === "create")).toBe(true); expect(copied.get("/home/kong/localhost.crt")).toBe(LEGACY_KONG_LOCAL_TLS_CERT); expect(copied.get("/home/kong/localhost.key")).toBe(LEGACY_KONG_LOCAL_TLS_KEY); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_API_ENABLED"]; - else process.env["SUPABASE_API_ENABLED"] = previous; - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_API_ENABLED", () => { - const previous = process.env["SUPABASE_API_ENABLED"]; - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; - const { layer, child } = setup(); + const { layer, child } = setup({ env: { SUPABASE_API_ENABLED: "not-a-bool" } }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_API_ENABLED"]; - else process.env["SUPABASE_API_ENABLED"] = previous; - }), - ), - ); + }).pipe(Effect.provide(layer)); }, ); }); describe("SUPABASE_AUTH_JWT_EXPIRY reaches Postgres init", () => { it.live("honors the override for Postgres's JWT_EXP, not just GoTrue's GOTRUE_JWT_EXP", () => { - const previous = process.env["SUPABASE_AUTH_JWT_EXPIRY"]; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "7200"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_JWT_EXPIRY", "7200"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4741,8 +4835,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_JWT_EXPIRY"]; - else process.env["SUPABASE_AUTH_JWT_EXPIRY"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_JWT_EXPIRY", undefined); + else vi.stubEnv("SUPABASE_AUTH_JWT_EXPIRY", previous); }), ), ); @@ -4751,8 +4845,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("encrypted secrets reach GoTrue's container", () => { it.live("decrypts an encrypted external OAuth provider secret (known provider)", () => { - const previous = process.env["DOTENV_PRIVATE_KEY"]; - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const previous = undefined; + vi.stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const { layer, child } = setup({ configContents: `project_id = "demo"\n[auth.external.github]\nenabled = true\nclient_id = "gh-client-id"\nsecret = "${VAULT_ENCRYPTED}"\n`, }); @@ -4766,8 +4860,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; + if (previous === undefined) vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); + else vi.stubEnv("DOTENV_PRIVATE_KEY", previous); }), ), ); @@ -4776,8 +4870,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "decrypts an encrypted external OAuth provider secret (custom/unmodeled provider)", () => { - const previous = process.env["DOTENV_PRIVATE_KEY"]; - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const previous = undefined; + vi.stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const { layer, child } = setup({ configContents: `project_id = "demo"\n[auth.external.my_oidc]\nenabled = true\nclient_id = "custom-client-id"\nsecret = "${VAULT_ENCRYPTED}"\n`, }); @@ -4791,8 +4885,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; + if (previous === undefined) vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); + else vi.stubEnv("DOTENV_PRIVATE_KEY", previous); }), ), ); @@ -4800,8 +4894,8 @@ content_path = "./supabase/templates/custom_notice.html" ); it.live("decrypts an encrypted Twilio SMS auth_token", () => { - const previous = process.env["DOTENV_PRIVATE_KEY"]; - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const previous = undefined; + vi.stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const { layer, child } = setup({ configContents: `project_id = "demo"\n[auth.sms.twilio]\nenabled = true\naccount_sid = "AC123"\nauth_token = "${VAULT_ENCRYPTED}"\nmessage_service_sid = "MG123"\n`, }); @@ -4815,8 +4909,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; + if (previous === undefined) vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); + else vi.stubEnv("DOTENV_PRIVATE_KEY", previous); }), ), ); @@ -4838,7 +4932,7 @@ content_path = "./supabase/templates/custom_notice.html" const envFileIndex = args.indexOf("--env-file"); const envFilePath = envFileIndex !== -1 ? args[envFileIndex + 1] : undefined; expect(envFilePath).toBeDefined(); - const envFileContent = readFileSync(envFilePath ?? "", "utf-8"); + const envFileContent = yield* readFile(envFilePath ?? ""); expect(envFileContent).toContain("MY_SECRET=shh-do-not-tell"); // Names reach the container UPPERCASED — every secret key is uppercased // — and empty values are skipped, shared with @@ -4857,8 +4951,8 @@ content_path = "./supabase/templates/custom_notice.html" // `edge_runtime.secrets` — this field decrypts during config load too, so the real // Edge Runtime container's env file must contain the decrypted "value", never the // literal `encrypted:...` string. - const previous = process.env["DOTENV_PRIVATE_KEY"]; - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + const previous = undefined; + vi.stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const { layer, child } = setup({ configContents: `project_id = "demo"\n[edge_runtime.secrets]\nMY_SECRET = "${VAULT_ENCRYPTED}"\n`, }); @@ -4869,15 +4963,15 @@ content_path = "./supabase/templates/custom_notice.html" const envFileIndex = args.indexOf("--env-file"); const envFilePath = envFileIndex !== -1 ? args[envFileIndex + 1] : undefined; expect(envFilePath).toBeDefined(); - const envFileContent = readFileSync(envFilePath ?? "", "utf-8"); + const envFileContent = yield* readFile(envFilePath ?? ""); expect(envFileContent).toContain("MY_SECRET=value"); expect(envFileContent).not.toContain("encrypted:"); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; + if (previous === undefined) vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); + else vi.stubEnv("DOTENV_PRIVATE_KEY", previous); }), ), ); @@ -4891,8 +4985,8 @@ content_path = "./supabase/templates/custom_notice.html" // (`edge_runtime.secrets.*` is one of `LEGACY_SECRET_PATHS`), well before the bring-up // loop's own edge-runtime-specific decrypt — same shape as the sibling `[db.vault]` // "even on a non-fresh volume" test above. - const previous = process.env["DOTENV_PRIVATE_KEY"]; - delete process.env["DOTENV_PRIVATE_KEY"]; + const previous = undefined; + vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); const { layer, child } = setup({ configContents: `project_id = "demo"\n[edge_runtime.secrets]\nMY_SECRET = "${VAULT_ENCRYPTED}"\n`, }); @@ -4900,7 +4994,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyDbConfigLoadError"); expect(serialized).toContain("failed to parse config: missing private key"); } @@ -4909,8 +5003,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; + if (previous === undefined) vi.stubEnv("DOTENV_PRIVATE_KEY", undefined); + else vi.stubEnv("DOTENV_PRIVATE_KEY", previous); }), ), ); @@ -4920,12 +5014,12 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_API_* env overrides reach PostgREST and Studio", () => { it.live("honors SUPABASE_API_SCHEMAS/_EXTRA_SEARCH_PATH/_MAX_ROWS in both containers", () => { - const previousSchemas = process.env["SUPABASE_API_SCHEMAS"]; - const previousSearchPath = process.env["SUPABASE_API_EXTRA_SEARCH_PATH"]; - const previousMaxRows = process.env["SUPABASE_API_MAX_ROWS"]; - process.env["SUPABASE_API_SCHEMAS"] = "public,custom"; - process.env["SUPABASE_API_EXTRA_SEARCH_PATH"] = "extensions,other"; - process.env["SUPABASE_API_MAX_ROWS"] = "500"; + const previousSchemas = undefined; + const previousSearchPath = undefined; + const previousMaxRows = undefined; + vi.stubEnv("SUPABASE_API_SCHEMAS", "public,custom"); + vi.stubEnv("SUPABASE_API_EXTRA_SEARCH_PATH", "extensions,other"); + vi.stubEnv("SUPABASE_API_MAX_ROWS", "500"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -4945,13 +5039,13 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previousSchemas === undefined) delete process.env["SUPABASE_API_SCHEMAS"]; - else process.env["SUPABASE_API_SCHEMAS"] = previousSchemas; + if (previousSchemas === undefined) vi.stubEnv("SUPABASE_API_SCHEMAS", undefined); + else vi.stubEnv("SUPABASE_API_SCHEMAS", previousSchemas); if (previousSearchPath === undefined) - delete process.env["SUPABASE_API_EXTRA_SEARCH_PATH"]; - else process.env["SUPABASE_API_EXTRA_SEARCH_PATH"] = previousSearchPath; - if (previousMaxRows === undefined) delete process.env["SUPABASE_API_MAX_ROWS"]; - else process.env["SUPABASE_API_MAX_ROWS"] = previousMaxRows; + vi.stubEnv("SUPABASE_API_EXTRA_SEARCH_PATH", undefined); + else vi.stubEnv("SUPABASE_API_EXTRA_SEARCH_PATH", previousSearchPath); + if (previousMaxRows === undefined) vi.stubEnv("SUPABASE_API_MAX_ROWS", undefined); + else vi.stubEnv("SUPABASE_API_MAX_ROWS", previousMaxRows); }), ), ); @@ -4960,22 +5054,22 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_API_MAX_ROWS", () => { - const previous = process.env["SUPABASE_API_MAX_ROWS"]; - process.env["SUPABASE_API_MAX_ROWS"] = "not-a-number"; + const previous = undefined; + vi.stubEnv("SUPABASE_API_MAX_ROWS", "not-a-number"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_API_MAX_ROWS"]; - else process.env["SUPABASE_API_MAX_ROWS"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_API_MAX_ROWS", undefined); + else vi.stubEnv("SUPABASE_API_MAX_ROWS", previous); }), ), ); @@ -4985,8 +5079,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_DB_POOLER_* env overrides reach Supavisor", () => { it.live("SUPABASE_DB_POOLER_POOL_MODE=session flips the published host port to 5432", () => { - const previous = process.env["SUPABASE_DB_POOLER_POOL_MODE"]; - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "session"; + const previous = undefined; + vi.stubEnv("SUPABASE_DB_POOLER_POOL_MODE", "session"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[db.pooler]\nenabled = true\n', }); @@ -5005,8 +5099,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_POOLER_POOL_MODE"]; - else process.env["SUPABASE_DB_POOLER_POOL_MODE"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_DB_POOLER_POOL_MODE", undefined); + else vi.stubEnv("SUPABASE_DB_POOLER_POOL_MODE", previous); }), ), ); @@ -5015,8 +5109,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_DB_POOLER_POOL_MODE", () => { - const previous = process.env["SUPABASE_DB_POOLER_POOL_MODE"]; - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "bogus"; + const previous = undefined; + vi.stubEnv("SUPABASE_DB_POOLER_POOL_MODE", "bogus"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[db.pooler]\nenabled = true\n', }); @@ -5024,15 +5118,15 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_POOLER_POOL_MODE"]; - else process.env["SUPABASE_DB_POOLER_POOL_MODE"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_DB_POOLER_POOL_MODE", undefined); + else vi.stubEnv("SUPABASE_DB_POOLER_POOL_MODE", previous); }), ), ); @@ -5042,22 +5136,22 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_REALTIME_ENABLED", () => { - const previous = process.env["SUPABASE_REALTIME_ENABLED"]; - process.env["SUPABASE_REALTIME_ENABLED"] = "maybe"; + const previous = undefined; + vi.stubEnv("SUPABASE_REALTIME_ENABLED", "maybe"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n' }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStartInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStartInvalidConfigError"); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); }).pipe( Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_REALTIME_ENABLED"]; - else process.env["SUPABASE_REALTIME_ENABLED"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_REALTIME_ENABLED", undefined); + else vi.stubEnv("SUPABASE_REALTIME_ENABLED", previous); }), ), ); @@ -5065,8 +5159,8 @@ content_path = "./supabase/templates/custom_notice.html" ); it.live("SUPABASE_DB_POOLER_PORT overrides the published host port", () => { - const previous = process.env["SUPABASE_DB_POOLER_PORT"]; - process.env["SUPABASE_DB_POOLER_PORT"] = "60001"; + const previous = undefined; + vi.stubEnv("SUPABASE_DB_POOLER_PORT", "60001"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[db.pooler]\nenabled = true\n', }); @@ -5083,8 +5177,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_POOLER_PORT"]; - else process.env["SUPABASE_DB_POOLER_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_DB_POOLER_PORT", undefined); + else vi.stubEnv("SUPABASE_DB_POOLER_PORT", previous); }), ), ); @@ -5093,8 +5187,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_ANALYTICS_PORT override", () => { it.live("overrides the published Logflare host port", () => { - const previous = process.env["SUPABASE_ANALYTICS_PORT"]; - process.env["SUPABASE_ANALYTICS_PORT"] = "60002"; + const previous = undefined; + vi.stubEnv("SUPABASE_ANALYTICS_PORT", "60002"); const { layer, child } = setup(); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -5108,8 +5202,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_ANALYTICS_PORT"]; - else process.env["SUPABASE_ANALYTICS_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_ANALYTICS_PORT", undefined); + else vi.stubEnv("SUPABASE_ANALYTICS_PORT", previous); }), ), ); @@ -5123,14 +5217,14 @@ content_path = "./supabase/templates/custom_notice.html" // resolved value, but a malformed override must still fail eagerly, same reasoning as // the SUPABASE_LOCAL_SMTP_SMTP_PORT/SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT tests elsewhere // in this file. - const previous = process.env["SUPABASE_ANALYTICS_VECTOR_PORT"]; - process.env["SUPABASE_ANALYTICS_VECTOR_PORT"] = "not-a-port"; + const previous = undefined; + vi.stubEnv("SUPABASE_ANALYTICS_VECTOR_PORT", "not-a-port"); const { layer, child } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStartInvalidConfigError"); expect(serialized).toContain("invalid config for analytics.vector_port"); } @@ -5139,8 +5233,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_ANALYTICS_VECTOR_PORT"]; - else process.env["SUPABASE_ANALYTICS_VECTOR_PORT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_ANALYTICS_VECTOR_PORT", undefined); + else vi.stubEnv("SUPABASE_ANALYTICS_VECTOR_PORT", previous); }), ), ); @@ -5152,8 +5246,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "honors an env-overridden health_timeout, not just the config.toml/default value", () => { - const previous = process.env["SUPABASE_DB_HEALTH_TIMEOUT"]; - process.env["SUPABASE_DB_HEALTH_TIMEOUT"] = "2s"; + const previous = undefined; + vi.stubEnv("SUPABASE_DB_HEALTH_TIMEOUT", "2s"); const neverHealthy = new Set<string>(); const base = defaultRoute({ neverHealthy }); const route = (args: ReadonlyArray<string>): RouteResult => { @@ -5168,7 +5262,7 @@ content_path = "./supabase/templates/custom_notice.html" const exit = yield* Effect.exit(legacyStart(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyHealthCheckTimeoutError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyHealthCheckTimeoutError"); } // Postgres's own health wait fails before any other service is ever created — // proving the short env-overridden timeout took effect (the default is much longer). @@ -5177,8 +5271,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_HEALTH_TIMEOUT"]; - else process.env["SUPABASE_DB_HEALTH_TIMEOUT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_DB_HEALTH_TIMEOUT", undefined); + else vi.stubEnv("SUPABASE_DB_HEALTH_TIMEOUT", previous); }), ), ); @@ -5189,14 +5283,16 @@ content_path = "./supabase/templates/custom_notice.html" describe("auth.hook.* env overrides reach GoTrue's container", () => { it.live("honors SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED/_URI", () => { - const previousEnabled = process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; - const previousUri = process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; + const previousEnabled = undefined; + const previousUri = undefined; + vi.stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", "true"); // A pg-functions URI needs no `secrets` (unlike http/https, validated by // `legacyValidateResolvedConfig`), keeping this scenario focused on the // enabled/uri override reaching GoTrue. - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = - "pg-functions://postgres/auth/custom-access-token-hook"; + vi.stubEnv( + "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", + "pg-functions://postgres/auth/custom-access-token-hook", + ); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.hook.custom_access_token]\nenabled = false\n', }); @@ -5214,11 +5310,11 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousEnabled === undefined) - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; - else process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = previousEnabled; + vi.stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", previousEnabled); if (previousUri === undefined) - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; - else process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = previousUri; + vi.stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", undefined); + else vi.stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", previousUri); }), ), ); @@ -5227,10 +5323,10 @@ content_path = "./supabase/templates/custom_notice.html" describe("auth.captcha.* env overrides reach GoTrue's container", () => { it.live("honors SUPABASE_AUTH_CAPTCHA_ENABLED/_PROVIDER", () => { - const previousEnabled = process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - const previousProvider = process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; + const previousEnabled = undefined; + const previousProvider = undefined; + vi.stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "true"); + vi.stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", "turnstile"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.captcha]\nenabled = false\nprovider = "hcaptcha"\nsecret = "test-secret"\n', @@ -5246,11 +5342,12 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previousEnabled === undefined) delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - else process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = previousEnabled; + if (previousEnabled === undefined) + vi.stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", previousEnabled); if (previousProvider === undefined) - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - else process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = previousProvider; + vi.stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", undefined); + else vi.stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", previousProvider); }), ), ); @@ -5259,8 +5356,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("nested auth security env overrides reach GoTrue's container", () => { it.live("honors SUPABASE_AUTH_SESSIONS_TIMEBOX", () => { - const previous = process.env["SUPABASE_AUTH_SESSIONS_TIMEBOX"]; - process.env["SUPABASE_AUTH_SESSIONS_TIMEBOX"] = "24h"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_SESSIONS_TIMEBOX", "24h"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n' }); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -5272,18 +5369,18 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_SESSIONS_TIMEBOX"]; - else process.env["SUPABASE_AUTH_SESSIONS_TIMEBOX"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_SESSIONS_TIMEBOX", undefined); + else vi.stubEnv("SUPABASE_AUTH_SESSIONS_TIMEBOX", previous); }), ), ); }); it.live("honors SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED/_VERIFY_ENABLED", () => { - const previousEnroll = process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - const previousVerify = process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; + const previousEnroll = undefined; + const previousVerify = undefined; + vi.stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "true"); + vi.stubEnv("SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", "true"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.mfa.totp]\nenroll_enabled = false\n', }); @@ -5299,19 +5396,19 @@ content_path = "./supabase/templates/custom_notice.html" Effect.ensuring( Effect.sync(() => { if (previousEnroll === undefined) - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - else process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = previousEnroll; + vi.stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", previousEnroll); if (previousVerify === undefined) - delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; - else process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = previousVerify; + vi.stubEnv("SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", previousVerify); }), ), ); }); it.live("honors SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", () => { - const previous = process.env["SUPABASE_AUTH_RATE_LIMIT_SMS_SENT"]; - process.env["SUPABASE_AUTH_RATE_LIMIT_SMS_SENT"] = "99"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", "99"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n' }); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -5323,16 +5420,16 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_RATE_LIMIT_SMS_SENT"]; - else process.env["SUPABASE_AUTH_RATE_LIMIT_SMS_SENT"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", undefined); + else vi.stubEnv("SUPABASE_AUTH_RATE_LIMIT_SMS_SENT", previous); }), ), ); }); it.live("honors SUPABASE_AUTH_WEB3_SOLANA_ENABLED", () => { - const previous = process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"]; - process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"] = "true"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_WEB3_SOLANA_ENABLED", "true"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n' }); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -5344,16 +5441,16 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"]; - else process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_WEB3_SOLANA_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_WEB3_SOLANA_ENABLED", previous); }), ), ); }); it.live("honors SUPABASE_AUTH_OAUTH_SERVER_ENABLED", () => { - const previous = process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"]; - process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"] = "true"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_OAUTH_SERVER_ENABLED", "true"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n' }); return Effect.gen(function* () { yield* legacyStart(flags()); @@ -5365,8 +5462,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"]; - else process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_OAUTH_SERVER_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_OAUTH_SERVER_ENABLED", previous); }), ), ); @@ -5375,8 +5472,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("auth.passkey/auth.webauthn env overrides reach GoTrue's container", () => { it.live("honors SUPABASE_AUTH_PASSKEY_ENABLED", () => { - const previous = process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; + const previous = undefined; + vi.stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", "true"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.passkey]\nenabled = false\n[auth.webauthn]\nrp_id = "localhost"\nrp_origins = ["http://localhost:3000"]\n', @@ -5391,20 +5488,20 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - else process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", undefined); + else vi.stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", previous); }), ), ); }); it.live("honors SUPABASE_AUTH_WEBAUTHN_RP_ID/_RP_DISPLAY_NAME/_RP_ORIGINS", () => { - const previousRpId = process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - const previousDisplayName = process.env["SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME"]; - const previousOrigins = process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "env-rp-id"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME"] = "Env Display Name"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://a.example,http://b.example"; + const previousRpId = undefined; + const previousDisplayName = undefined; + const previousOrigins = undefined; + vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", "env-rp-id"); + vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", "Env Display Name"); + vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", "http://a.example,http://b.example"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.webauthn]\nrp_id = "toml-rp-id"\nrp_display_name = "TOML Display Name"\nrp_origins = ["http://toml.example"]\n', @@ -5423,14 +5520,14 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previousRpId === undefined) delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - else process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = previousRpId; + if (previousRpId === undefined) vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", undefined); + else vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", previousRpId); if (previousDisplayName === undefined) - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME"]; - else process.env["SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME"] = previousDisplayName; + vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", undefined); + else vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_DISPLAY_NAME", previousDisplayName); if (previousOrigins === undefined) - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - else process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = previousOrigins; + vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined); + else vi.stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", previousOrigins); }), ), ); @@ -5443,8 +5540,8 @@ content_path = "./supabase/templates/custom_notice.html" // `env(...)` walker substitutes the real value but leaves it a raw string (no type // coercion for schema-unmodeled paths) — a strict `=== true` check would silently read // this valid config as disabled. - const previous = process.env["PASSKEY_ENABLED"]; - process.env["PASSKEY_ENABLED"] = "true"; + const previous = undefined; + vi.stubEnv("PASSKEY_ENABLED", "true"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.passkey]\nenabled = "env(PASSKEY_ENABLED)"\n[auth.webauthn]\nrp_id = "localhost"\nrp_origins = ["http://localhost:3000"]\n', @@ -5459,8 +5556,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["PASSKEY_ENABLED"]; - else process.env["PASSKEY_ENABLED"] = previous; + if (previous === undefined) vi.stubEnv("PASSKEY_ENABLED", undefined); + else vi.stubEnv("PASSKEY_ENABLED", previous); }), ), ); @@ -5470,8 +5567,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "splits an env(...)-resolved comma-separated rp_origins string instead of dropping it to []", () => { - const previous = process.env["RP_ORIGINS"]; - process.env["RP_ORIGINS"] = "http://a.example,http://b.example"; + const previous = undefined; + vi.stubEnv("RP_ORIGINS", "http://a.example,http://b.example"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.passkey]\nenabled = true\n[auth.webauthn]\nrp_id = "localhost"\nrp_origins = "env(RP_ORIGINS)"\n', @@ -5488,8 +5585,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["RP_ORIGINS"]; - else process.env["RP_ORIGINS"] = previous; + if (previous === undefined) vi.stubEnv("RP_ORIGINS", undefined); + else vi.stubEnv("RP_ORIGINS", previous); }), ), ); @@ -5499,8 +5596,8 @@ content_path = "./supabase/templates/custom_notice.html" describe("SUPABASE_EDGE_RUNTIME_POLICY override", () => { it.live("honors the env-overridden Edge Runtime request policy", () => { - const previous = process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "per_worker"; + const previous = undefined; + vi.stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", "per_worker"); const { layer, child } = setup({ configContents: 'project_id = "demo"\n[edge_runtime]\npolicy = "oneshot"\n', }); @@ -5514,8 +5611,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.ensuring( Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - else process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = previous; + if (previous === undefined) vi.stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", undefined); + else vi.stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", previous); }), ), ); diff --git a/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts index 2bc0483e9c..b656fb6045 100644 --- a/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/legacy/commands/start/start.lifecycle.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/new-promise, effecttsgo/node-builtin-import -- this e2e test owns real subprocess lifecycle callbacks. import { execFile } from "node:child_process"; import { once } from "node:events"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -280,7 +281,7 @@ describe("supabase start (e2e)", () => { const mailpitContainer = legacyServiceContainerName("inbucket", projectId); // The exact tag `start` resolves for Mailpit, so its already-cached check // finds this deliberately broken build and never reaches a registry. - const mailpitImage = legacyGetRegistryImageUrl(dockerfileServiceImage("mailpit")); + const mailpitImage = legacyGetRegistryImageUrl(dockerfileServiceImage("mailpit"), {}); const init = await runSupabase(["init"], { entrypoint: "legacy", diff --git a/apps/cli/src/legacy/commands/status/status.command.unit.test.ts b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts index 8b60cfca52..e6270cfa0c 100644 --- a/apps/cli/src/legacy/commands/status/status.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/status/status.command.unit.test.ts @@ -1,130 +1,84 @@ import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; import { describe, expect, test } from "vitest"; + import { normalizeCause } from "../../../shared/output/normalize-error.ts"; import { legacyStatusExcludeFlag, legacyStatusOverrideNameFlag } from "./status.command.ts"; -describe("legacy status --override-name flag (pflag StringSlice parity)", () => { - test("splits a comma-separated value into multiple overrides", async () => { - const [, overrideName] = await Effect.runPromise( - legacyStatusOverrideNameFlag - .parse({ - flags: { "override-name": ["api.url=FOO,db.url=BAR"] }, - arguments: [], - }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR"]); - }); - - test("accumulates repeated occurrences, each CSV-split", async () => { - const [, overrideName] = await Effect.runPromise( - legacyStatusOverrideNameFlag - .parse({ - flags: { "override-name": ["api.url=FOO,db.url=BAR", "studio.url=BAZ"] }, - arguments: [], - }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR", "studio.url=BAZ"]); - }); - - test("defaults to an empty array when unset", async () => { - const [, overrideName] = await Effect.runPromise( - legacyStatusOverrideNameFlag - .parse({ flags: {}, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(overrideName).toEqual([]); - }); - - test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Verified against pflag's actual CSV behavior (CLI-2005): `status --override-name $'a=1\nb"2'` - // raises no parse error — pflag calls `csv.Reader.Read()` once, so the malformed - // second line is silently dropped. - const [, overrideName] = await Effect.runPromise( - legacyStatusOverrideNameFlag - .parse({ flags: { "override-name": ['a=1\nb"2'] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(overrideName).toEqual(["a=1"]); - }); +const parseOverride = (flags: Record<string, ReadonlyArray<string>>) => + legacyStatusOverrideNameFlag + .parse({ flags, arguments: [] }) + .pipe(Effect.provide(BunServices.layer)); - test("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacyStatusOverrideNameFlag - .parse({ flags: { "override-name": ['"api.url=FOO'] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); +const parseExclude = (flags: Record<string, ReadonlyArray<string>>) => + legacyStatusExcludeFlag.parse({ flags, arguments: [] }).pipe(Effect.provide(BunServices.layer)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Matches the established pflag CSV error text (`"api.url=FOO` is 12 bytes → EOF at column 13). - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\"api.url=FOO" for "--override-name" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field', - ); - } - }); - - test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Verified against pflag's actual output (CLI-2005): `status --override-name $'\n'` → - // `invalid argument "\n" for "--override-name" flag: EOF`. - const exit = await Effect.runPromise( - legacyStatusOverrideNameFlag - .parse({ flags: { "override-name": ["\n"] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "\\n" for "--override-name" flag: EOF', - ); - } - }); +describe("legacy status --override-name flag (pflag StringSlice parity)", () => { + test("splits a comma-separated value into multiple overrides", () => + Effect.runPromise(parseOverride({ "override-name": ["api.url=FOO,db.url=BAR"] })).then( + ([, overrideName]) => { + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR"]); + }, + )); + + test("accumulates repeated occurrences, each CSV-split", () => + Effect.runPromise( + parseOverride({ "override-name": ["api.url=FOO,db.url=BAR", "studio.url=BAZ"] }), + ).then(([, overrideName]) => { + expect(overrideName).toEqual(["api.url=FOO", "db.url=BAR", "studio.url=BAZ"]); + })); + + test("defaults to an empty array when unset", () => + Effect.runPromise(parseOverride({})).then(([, overrideName]) => { + expect(overrideName).toEqual([]); + })); + + test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", () => + Effect.runPromise(parseOverride({ "override-name": ['a=1\nb"2'] })).then(([, overrideName]) => { + expect(overrideName).toEqual(["a=1"]); + })); + + test("rejects malformed CSV (unterminated quote) with pflag's exact diagnostic", () => + Effect.runPromise(parseOverride({ "override-name": ['"api.url=FOO'] }).pipe(Effect.exit)).then( + (exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\"api.url=FOO" for "--override-name" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field', + ); + } + }, + )); + + test("rejects a blank-only value with pflag's EOF diagnostic", () => + Effect.runPromise(parseOverride({ "override-name": ["\n"] }).pipe(Effect.exit)).then((exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "\\n" for "--override-name" flag: EOF', + ); + } + })); }); describe("legacy status --exclude flag (pflag StringSlice parity)", () => { - test("splits a comma-separated value into multiple exclusions", async () => { - const [, exclude] = await Effect.runPromise( - legacyStatusExcludeFlag - .parse({ flags: { exclude: ["kong,auth"] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(exclude).toEqual(["kong", "auth"]); - }); - - test("defaults to an empty array when unset", async () => { - const [, exclude] = await Effect.runPromise( - legacyStatusExcludeFlag - .parse({ flags: {}, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)), - ); - - expect(exclude).toEqual([]); - }); - - test("rejects malformed CSV (bare quote) with pflag's exact diagnostic", async () => { - const exit = await Effect.runPromise( - legacyStatusExcludeFlag - .parse({ flags: { exclude: ['a"b'] }, arguments: [] }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - // Verified against pflag's actual output (CLI-2005): `status --exclude 'a"b'` — bare quote at byte 2. - expect(normalizeCause(exit.cause).message).toBe( - 'invalid argument "a\\"b" for "--exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field', - ); - } - }); + test("splits a comma-separated value into multiple exclusions", () => + Effect.runPromise(parseExclude({ exclude: ["kong,auth"] })).then(([, exclude]) => { + expect(exclude).toEqual(["kong", "auth"]); + })); + + test("defaults to an empty array when unset", () => + Effect.runPromise(parseExclude({})).then(([, exclude]) => { + expect(exclude).toEqual([]); + })); + + test("rejects malformed CSV (bare quote) with pflag's exact diagnostic", () => + Effect.runPromise(parseExclude({ exclude: ['a"b'] }).pipe(Effect.exit)).then((exit) => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(normalizeCause(exit.cause).message).toBe( + 'invalid argument "a\\"b" for "--exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field', + ); + } + })); }); diff --git a/apps/cli/src/legacy/commands/status/status.e2e.test.ts b/apps/cli/src/legacy/commands/status/status.e2e.test.ts index 5b4e47413d..4889bb6980 100644 --- a/apps/cli/src/legacy/commands/status/status.e2e.test.ts +++ b/apps/cli/src/legacy/commands/status/status.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this e2e test uses Vitest's Promise surface to drive the real CLI. import { afterEach, expect, test } from "vitest"; import { describe } from "vitest"; diff --git a/apps/cli/src/legacy/commands/status/status.handler.ts b/apps/cli/src/legacy/commands/status/status.handler.ts index 28527d281f..d7057a3e58 100644 --- a/apps/cli/src/legacy/commands/status/status.handler.ts +++ b/apps/cli/src/legacy/commands/status/status.handler.ts @@ -173,20 +173,20 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS // signing-keys-file read/parse error — all of these must fail here, not // be masked by a Docker/DB error when the local stack happens to be // unavailable. - const localState = yield* Effect.try({ - try: () => - legacyResolveStatusLocalState( - context.config, - context.hostname, - cliConfig.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - catch: (cause) => - new LegacyStatusInvalidConfigError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const localState = yield* legacyResolveStatusLocalState( + context.config, + context.hostname, + cliConfig.workdir, + context.projectEnvValues, + context.loaded?.document, + ).pipe( + Effect.mapError( + (cause) => + new LegacyStatusInvalidConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + ), + ); // 4. status has no --project-id flag; resolution is always env → toml → // workdir basename, then sanitized to match the singleton config @@ -209,18 +209,14 @@ export const legacyStatus = Effect.fn("legacy.status")(function* (flags: LegacyS Effect.mapError((cause) => new LegacyStatusDbInspectError({ message: cause.message })), ); if (!state.running) { - return yield* Effect.fail( - new LegacyStatusDbNotRunningError({ - message: `${dbContainerId} container is not running: ${state.status}`, - }), - ); + return yield* new LegacyStatusDbNotRunningError({ + message: `${dbContainerId} container is not running: ${state.status}`, + }); } if (state.health !== undefined && state.health !== "healthy") { - return yield* Effect.fail( - new LegacyStatusDbNotReadyError({ - message: `${dbContainerId} container is not ready: ${state.health}`, - }), - ); + return yield* new LegacyStatusDbNotReadyError({ + message: `${dbContainerId} container is not ready: ${state.health}`, + }); } } diff --git a/apps/cli/src/legacy/commands/status/status.integration.test.ts b/apps/cli/src/legacy/commands/status/status.integration.test.ts index ef596c870e..7e9fed029c 100644 --- a/apps/cli/src/legacy/commands/status/status.integration.test.ts +++ b/apps/cli/src/legacy/commands/status/status.integration.test.ts @@ -1,14 +1,28 @@ import { generateKeyPairSync } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; - import type { ApiClient, V1ListAllBranchesOutput } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stdio, Stream } from "effect"; +import { + ConfigProvider, + Data, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + PlatformError, + Schema, + Sink, + Stdio, + Stream, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClientRequestModule from "effect/unstable/http/HttpClientRequest"; -import { afterEach, vi } from "vitest"; +import { vi } from "vitest"; import { mockOutput, mockProcessControl } from "../../../../tests/helpers/mocks.ts"; import { @@ -27,17 +41,47 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { machineErrorContextLayer } from "../../../shared/output/machine-error-context.layer.ts"; import { jsonOutputLayer, streamJsonOutputLayer } from "../../../shared/output/output.layer.ts"; import { legacyServiceContainerIds, localDbContainerId } from "../../shared/legacy-docker-ids.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import type { LegacyStatusFlags } from "./status.command.ts"; import { legacyStatus } from "./status.handler.ts"; type LinkedStateBranches = typeof V1ListAllBranchesOutput.Type; type LinkedStateBranch = LinkedStateBranches[number]; -const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); +class MockLegacyStatusApiError extends Data.TaggedError("MockLegacyStatusApiError")<{ + readonly cause: unknown; +}> {} -afterEach(() => { - delete process.env["SUPABASE_AUTH_JWT_SECRET"]; +const tempRoot = useLegacyTempWorkdir("supabase-status-int-"); +const testPath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); + +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const StatusOutputSchema = Schema.Record(Schema.String, Schema.String); +const StatusOutputCodec = Schema.fromJsonString(StatusOutputSchema); +const decodeStatusOutput = (text: string): Readonly<Record<string, string>> => + Schema.decodeSync(StatusOutputCodec)(text); + +const LinkedProjectOutputSchema = Schema.Struct({ + project_ref: Schema.String, + branch: Schema.optional(Schema.String), + parent_project_ref: Schema.optional(Schema.String), + project_name: Schema.optional(Schema.String), + org_slug: Schema.optional(Schema.String), + org_id: Schema.optional(Schema.String), }); +const StatusErrorSchema = Schema.TaggedStruct("Error", { + error: Schema.Struct({ code: Schema.String, message: Schema.String }), + linked_project: Schema.optional(Schema.Union([Schema.Null, LinkedProjectOutputSchema])), +}); +const StatusStreamErrorSchema = Schema.Struct({ + type: Schema.Literal("error"), + timestamp: Schema.String, + error: Schema.Struct({ code: Schema.String, message: Schema.String }), + linked_project: Schema.optional(Schema.Union([Schema.Null, LinkedProjectOutputSchema])), +}); +const decodeStatusError = Schema.decodeSync(Schema.fromJsonString(StatusErrorSchema)); +const decodeStatusStreamError = Schema.decodeSync(Schema.fromJsonString(StatusStreamErrorSchema)); function flags(overrides: Partial<LegacyStatusFlags> = {}): LegacyStatusFlags { return { @@ -48,10 +92,20 @@ function flags(overrides: Partial<LegacyStatusFlags> = {}): LegacyStatusFlags { }; } +const pendingWrites = new Map< + string, + Array<{ readonly path: string; readonly contents: string }> +>(); + +function writeText(path: string, contents: string): void { + const workdir = tempRoot.current; + const writes = pendingWrites.get(workdir) ?? []; + writes.push({ path, contents }); + pendingWrites.set(workdir, writes); +} + function writeConfig(workdir: string, contents = 'project_id = "demo"\n') { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), contents); + return writeText(testPath.join(workdir, "supabase", "config.toml"), contents); } // --------------------------------------------------------------------------- @@ -79,16 +133,15 @@ const LINKED_BRANCH: LinkedStateBranch = { }; function tempFile(workdir: string, name: string): string { - return join(workdir, "supabase", ".temp", name); + return testPath.join(workdir, "supabase", ".temp", name); } -function writeTempContent(workdir: string, name: string, content: string): void { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(tempFile(workdir, name), content); +function writeTempContent(workdir: string, name: string, content: string) { + return writeText(tempFile(workdir, name), content); } -function writeProjectRefFile(workdir: string, ref: string): void { - writeTempContent(workdir, "project-ref", ref); +function writeProjectRefFile(workdir: string, ref: string) { + return writeTempContent(workdir, "project-ref", ref); } /** @@ -104,13 +157,13 @@ function writeLinkedProjectCacheFile( readonly orgSlug?: string | null; readonly orgId?: string | null; } = {}, -): void { +) { const orgSlug = opts.orgSlug === undefined ? "acme" : opts.orgSlug; const orgId = opts.orgId === undefined ? "org_1" : opts.orgId; - writeTempContent( + return writeTempContent( workdir, "linked-project.json", - JSON.stringify({ + encodeJson({ ref, ...(opts.name === undefined ? {} : { name: opts.name }), ...(orgSlug === null ? {} : { organization_slug: orgSlug }), @@ -119,6 +172,19 @@ function writeLinkedProjectCacheFile( ); } +function flushFixtureWrites(workdir: string) { + return Effect.gen(function* () { + const roots = workdir === tempRoot.current ? [workdir] : [workdir, tempRoot.current]; + const writes = roots.flatMap((root) => pendingWrites.get(root) ?? []); + const fs = yield* FileSystem.FileSystem; + for (const write of writes) { + yield* fs.makeDirectory(testPath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + for (const root of roots) pendingWrites.delete(root); + }); +} + function legacyTransportFailureForMock() { return legacyTransportFailure(HttpClientRequestModule.get("https://api.supabase.com/mock")); } @@ -199,25 +265,21 @@ function mockRoutedContainerCliSpawner( spawned.push({ command: cmd, args }); if (opts.dockerMissing === true && cmd === "docker") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "docker not found", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }); } if (opts.failSpawnFor?.(args) === true) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } const encoder = new TextEncoder(); @@ -261,7 +323,7 @@ function mockRoutedContainerCliSpawner( } const ALL_RUNNING_NAMES = legacyServiceContainerIds("demo"); -const HEALTHY_DB_STATE = JSON.stringify({ +const HEALTHY_DB_STATE = encodeJson({ Status: "running", Running: true, Health: { Status: "healthy" }, @@ -322,6 +384,8 @@ interface SetupOpts { readonly fail?: unknown; readonly makeFails?: LegacyPlatformApiFactoryError; }; + /** Per-test environment values exposed through Effect's ConfigProvider. */ + readonly env?: Readonly<Record<string, string>>; /** `SUPABASE_PROJECT_ID` for the linked-state chain — defaults to unset. */ readonly projectId?: Option.Option<string>; } @@ -341,22 +405,30 @@ function setup(opts: SetupOpts = {}) { dockerMissing: opts.dockerMissing, failSpawnFor: opts.failSpawnFor, }); + const branches = opts.branches; const apiMock = - opts.branches === undefined + branches === undefined ? undefined : mockLegacyPlatformApiService({ v1: { listAllBranches: - opts.branches.fail !== undefined - ? () => Effect.fail(opts.branches?.fail) - : () => Effect.succeed(opts.branches?.ok ?? []), + branches.fail !== undefined + ? () => Effect.fail(new MockLegacyStatusApiError({ cause: branches.fail })) + : () => Effect.succeed(branches.ok ?? []), }, }); const apiFactoryMock = opts.apiFactory === undefined ? undefined : mockLegacyPlatformApiFactoryDirect(opts.apiFactory); + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const layer = Layer.mergeAll( BunServices.layer, + Layer.effectDiscard(flushFixtureWrites(workdir).pipe(Effect.provide(BunServices.layer))), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, cliConfig, telemetry.layer, @@ -432,21 +504,26 @@ function setupFailureEnvelope(opts: FailureEnvelopeOpts) { const cliConfig = mockLegacyCliConfig({ workdir, projectId: Option.none() }); const child = mockRoutedContainerCliSpawner(defaultRoute(), { failSpawnFor: () => true }); const processControl = mockProcessControl(); + const branches = opts.branches; const apiMock = - opts.branches === undefined + branches === undefined ? undefined : mockLegacyPlatformApiService({ v1: { listAllBranches: - opts.branches.fail !== undefined - ? () => Effect.fail(opts.branches?.fail) - : () => Effect.succeed(opts.branches?.ok ?? []), + branches.fail !== undefined + ? () => Effect.fail(new MockLegacyStatusApiError({ cause: branches.fail })) + : () => Effect.succeed(branches.ok ?? []), }, }); const outputLayer = opts.format === "json" ? jsonOutputLayer : streamJsonOutputLayer; + const configProvider = ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true }); const layer = Layer.mergeAll( BunServices.layer, + Layer.effectDiscard(flushFixtureWrites(workdir).pipe(Effect.provide(BunServices.layer))), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), + makeLegacyViperEnvLayer(configProvider), outputLayer.pipe(Layer.provide(stdio.layer)), cliConfig, telemetry.layer, @@ -521,7 +598,7 @@ describe("legacy status integration", () => { // the default) to cover both sides of the `if !ignoreHealthCheck { assertContainerHealthy }` gate. const { layer, child } = setup({ route: defaultRoute({ - dbInspectStdout: JSON.stringify({ + dbInspectStdout: encodeJson({ Status: "running", Running: true, Health: { Status: "starting" }, @@ -550,14 +627,13 @@ describe("legacy status integration", () => { it.live("fails when config.toml is malformed", () => { const workdir = tempRoot.current; - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), "not valid toml ====="); + writeText(testPath.join(workdir, "supabase", "config.toml"), "not valid toml ====="); const { layer, child } = setup({ skipConfig: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -570,9 +646,8 @@ describe("legacy status integration", () => { // `status` never binds a --project-ref flag, so it must still fail on a // config-wide duplicate, before ever reaching Docker. const workdir = tempRoot.current; - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "config.toml"), + writeText( + testPath.join(workdir, "supabase", "config.toml"), `project_id = "baseref" [remotes.a] @@ -587,7 +662,7 @@ project_id = "previewrefaaaaaaaaaa" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -599,9 +674,8 @@ project_id = "previewrefaaaaaaaaaa" // remote that ends up selected — so this must fail closed before status // reaches Docker, even with no --project-ref requested. const workdir = tempRoot.current; - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", "config.toml"), + writeText( + testPath.join(workdir, "supabase", "config.toml"), `project_id = "baseref" [remotes.bad] @@ -613,7 +687,7 @@ project_id = "short" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -631,9 +705,7 @@ project_id = "short" configContents: 'project_id = "demo"\n[auth]\nadditional_redirect_urls = "http://a,http://b"\n', }); - return Effect.gen(function* () { - yield* legacyStatus(flags()); - }).pipe(Effect.provide(layer)); + return legacyStatus(flags()).pipe(Effect.provide(layer)); }, ); @@ -661,14 +733,14 @@ project_id = "short" // The explicit workdir is `chdir`'d into before config // load or any Docker call — a missing path must fail immediately, not // fall through to the workdir-basename default and inspect Docker. - const missingWorkdir = join(tempRoot.current, "does-not-exist"); + const missingWorkdir = testPath.join(tempRoot.current, "does-not-exist"); const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(Formatter.formatJson(exit.cause)).toContain( `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, ); } @@ -677,15 +749,15 @@ project_id = "short" }); it.live("fails when --workdir/SUPABASE_WORKDIR points at a file, not a directory", () => { - const filePath = join(tempRoot.current, "not-a-directory"); - writeFileSync(filePath, ""); + const filePath = testPath.join(tempRoot.current, "not-a-directory"); + writeText(filePath, ""); const { layer, child } = setup({ workdir: filePath, skipConfig: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusWorkdirError"); - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusWorkdirError"); + expect(Formatter.formatJson(exit.cause)).toContain( `failed to change workdir: chdir ${filePath}: not a directory`, ); } @@ -705,8 +777,8 @@ project_id = "short" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusInvalidConfigError"); - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusInvalidConfigError"); + expect(Formatter.formatJson(exit.cause)).toContain( "Invalid config for auth.jwt_secret. Must be at least 16 characters", ); } @@ -724,11 +796,10 @@ enabled = true content_path = "./supabase/templates/password_changed_notification.html" `, }); - const templateDir = join(workdir, "supabase", "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "recovery.html"), "<p>Recovery</p>"); - writeFileSync( - join(templateDir, "password_changed_notification.html"), + const templateDir = testPath.join(workdir, "supabase", "templates"); + writeText(testPath.join(templateDir, "recovery.html"), "<p>Recovery</p>"); + writeText( + testPath.join(templateDir, "password_changed_notification.html"), "<p>Password changed</p>", ); @@ -745,8 +816,8 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, out } = setup({ goOutput: Option.some("env"), configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, + env: { SUPABASE_AUTH_JWT_SECRET: "b".repeat(32) }, }); - process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); return Effect.gen(function* () { yield* legacyStatus(flags()); expect(out.stdoutText).toContain(`JWT_SECRET="${"b".repeat(32)}"`); @@ -764,12 +835,12 @@ content_path = "./supabase/templates/password_changed_notification.html" }); const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = { ...privateKey.export({ format: "jwk" }), alg: "RS256", kid: "test-kid" }; - writeFileSync(join(workdir, "supabase", "signing_keys.json"), JSON.stringify([jwk])); + writeText(testPath.join(workdir, "supabase", "signing_keys.json"), encodeJson([jwk])); return Effect.gen(function* () { yield* legacyStatus(flags()); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); const [headerSegment] = parsed.ANON_KEY?.split(".") ?? []; - const header = JSON.parse(Buffer.from(headerSegment ?? "", "base64url").toString()); + const header = decodeStatusOutput(Buffer.from(headerSegment ?? "", "base64url").toString()); expect(header).toEqual({ alg: "RS256", kid: "test-kid", typ: "JWT" }); }).pipe(Effect.provide(layer)); }); @@ -784,7 +855,7 @@ content_path = "./supabase/templates/password_changed_notification.html" // basename (not the module-level `ALL_RUNNING_NAMES`, which is fixed to // "demo") — route `ps` off that so the expected services actually show as // running rather than all appearing "stopped" and excluded. - const projectId = basename(tempRoot.current); + const projectId = testPath.basename(tempRoot.current); const { layer, out } = setup({ skipConfig: true, route: defaultRoute({ runningNames: legacyServiceContainerIds(projectId) }), @@ -802,9 +873,8 @@ content_path = "./supabase/templates/password_changed_notification.html" // before reading SUPABASE_PROJECT_ID from the resolved environment — // an env-file-only value overrides // config.toml's project_id too, not just an ambient shell export. - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); + const supabaseDir = testPath.join(tempRoot.current, "supabase"); + writeText(testPath.join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); const { layer, child } = setup({ configContents: 'project_id = "toml-project"\n', route: defaultRoute({ runningNames: legacyServiceContainerIds("env-file-project") }), @@ -819,13 +889,12 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); - process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; + const supabaseDir = testPath.join(tempRoot.current, "supabase"); + writeText(testPath.join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); const { layer, child } = setup({ configContents: 'project_id = "toml-project"\n', route: defaultRoute({ runningNames: legacyServiceContainerIds("ambient-project") }), + env: { SUPABASE_PROJECT_ID: "ambient-project" }, }); return Effect.gen(function* () { yield* legacyStatus(flags()); @@ -833,17 +902,14 @@ content_path = "./supabase/templates/password_changed_notification.html" (s) => s.args[0] === "container" && s.args[1] === "inspect", ); expect(inspectCall?.args).toContain(localDbContainerId("ambient-project")); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), - ); + }).pipe(Effect.provide(layer)); }); it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { // The nested env load walks past supabase/ one more level, to the project // root/workdir — a project-root-only // dotenv value must override config.toml too, not just supabase/.env. - writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); + writeText(testPath.join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); const { layer, child } = setup({ configContents: 'project_id = "toml-project"\n', route: defaultRoute({ runningNames: legacyServiceContainerIds("root-env-project") }), @@ -866,10 +932,10 @@ content_path = "./supabase/templates/password_changed_notification.html" // own must fall back to defaults (workdir-basename project id), not an // ancestor project's config.toml, even though `cliConfig.workdir` sits // right inside one. - const nestedWorkdir = join(tempRoot.current, "nested"); - mkdirSync(nestedWorkdir, { recursive: true }); + const nestedWorkdir = testPath.join(tempRoot.current, "nested"); + writeText(testPath.join(nestedWorkdir, ".keep"), ""); writeConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); - const projectId = basename(nestedWorkdir); + const projectId = testPath.basename(nestedWorkdir); const { layer, child } = setup({ workdir: nestedWorkdir, skipConfig: true, @@ -891,9 +957,8 @@ content_path = "./supabase/templates/password_changed_notification.html" // opened — a supabase/.env-only project id // must still be honored even when there's no config.toml to fall back to // template defaults from. - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); + const supabaseDir = testPath.join(tempRoot.current, "supabase"); + writeText(testPath.join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); const { layer, child } = setup({ skipConfig: true, route: defaultRoute({ runningNames: legacyServiceContainerIds("no-config-project") }), @@ -912,9 +977,8 @@ content_path = "./supabase/templates/password_changed_notification.html" // before reading SUPABASE_AUTH_JWT_SECRET from the resolved environment — // a dotenv-file-only value must be visible here too, not just an ambient // shell export (see the sibling "-o env" ambient test above). - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); + const supabaseDir = testPath.join(tempRoot.current, "supabase"); + writeText(testPath.join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); const { layer, out } = setup({ goOutput: Option.some("env"), configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, @@ -935,7 +999,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbInspectError"); } }).pipe(Effect.provide(layer)); }); @@ -967,7 +1031,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusListError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusListError"); } }).pipe(Effect.provide(layer)); }); @@ -975,14 +1039,14 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live("fails when the db container is not running", () => { const { layer } = setup({ route: defaultRoute({ - dbInspectStdout: JSON.stringify({ Status: "exited", Running: false }), + dbInspectStdout: encodeJson({ Status: "exited", Running: false }), }), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStatusDbNotRunningError"); expect(serialized).toContain(localDbContainerId("demo")); } @@ -998,16 +1062,14 @@ content_path = "./supabase/templates/password_changed_notification.html" // past the not-running branch to the health check in that case. const { layer } = setup({ route: defaultRoute({ - dbInspectStdout: JSON.stringify({ + dbInspectStdout: encodeJson({ Status: "paused", Running: true, Health: { Status: "healthy" }, }), }), }); - return Effect.gen(function* () { - yield* legacyStatus(flags()); - }).pipe(Effect.provide(layer)); + return legacyStatus(flags()).pipe(Effect.provide(layer)); }, ); @@ -1025,7 +1087,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const serialized = JSON.stringify(exit.cause); + const serialized = Formatter.formatJson(exit.cause); expect(serialized).toContain("LegacyStatusDbInspectError"); expect(serialized).toContain( "failed to inspect container health: Error response from daemon: No such container: x", @@ -1037,7 +1099,7 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live("fails when the db container is unhealthy", () => { const { layer } = setup({ route: defaultRoute({ - dbInspectStdout: JSON.stringify({ + dbInspectStdout: encodeJson({ Status: "running", Running: true, Health: { Status: "starting" }, @@ -1048,7 +1110,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbNotReadyError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbNotReadyError"); } }).pipe(Effect.provide(layer)); }); @@ -1061,7 +1123,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbInspectError"); } }).pipe(Effect.provide(layer)); }); @@ -1079,7 +1141,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* legacyStatus(flags()); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); expect(parsed.DB_URL).toContain("postgresql://postgres:postgres@"); }).pipe(Effect.provide(layer)); @@ -1090,7 +1152,7 @@ content_path = "./supabase/templates/password_changed_notification.html" return Effect.gen(function* () { const storageId = legacyServiceContainerIds("demo")[5]!; yield* legacyStatus(flags({ exclude: [storageId] })); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.STORAGE_S3_URL).toBeUndefined(); expect(parsed.API_URL).toBeDefined(); }).pipe(Effect.provide(layer)); @@ -1102,7 +1164,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const authId = legacyServiceContainerIds("demo")[1]!; const storageId = legacyServiceContainerIds("demo")[5]!; yield* legacyStatus(flags({ exclude: [authId, storageId] })); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); expect(parsed.STORAGE_S3_URL).toBeUndefined(); expect(parsed.API_URL).toBeDefined(); @@ -1121,7 +1183,7 @@ content_path = "./supabase/templates/password_changed_notification.html" return Effect.gen(function* () { const authId = legacyServiceContainerIds("demo")[1]!; yield* legacyStatus(flags({ exclude: [authId] })); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.API_URL).toBeUndefined(); // excluded via the auto-detected stopped kong expect(parsed.PUBLISHABLE_KEY).toBeUndefined(); // excluded via --exclude expect(parsed.DB_URL).toBeDefined(); // db.url is set unconditionally, before any gating @@ -1148,7 +1210,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* legacyStatus(flags({ overrideName: ["api.url=NEXT_PUBLIC_SUPABASE_URL"] })); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); expect(parsed.API_URL).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -1160,7 +1222,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags({ overrideName: ["not-a-kv-pair"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusOverrideParseError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusOverrideParseError"); } }).pipe(Effect.provide(layer)); }); @@ -1172,7 +1234,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* legacyStatus(flags({ overrideName: ["not.a.real.field=NAME"] })); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.NAME).toBeUndefined(); expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); }).pipe(Effect.provide(layer)); @@ -1184,7 +1246,7 @@ content_path = "./supabase/templates/password_changed_notification.html" yield* legacyStatus( flags({ overrideName: ["not.a.real.field=NAME", "api.url=NEXT_PUBLIC_SUPABASE_URL"] }), ); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); expect(parsed.NAME).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -1625,7 +1687,7 @@ content_path = "./supabase/templates/password_changed_notification.html" writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { name: "Parent Project" }); return Effect.gen(function* () { yield* legacyStatus(flags()); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.linked_project_ref).toBe(LINKED_BRANCH_REF); expect(parsed.linked_parent_project_ref).toBe(LINKED_PARENT_REF); expect(parsed.linked_project_name).toBe("Parent Project"); @@ -1677,7 +1739,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const exit = yield* Effect.exit(legacyStatus(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStatusDbInspectError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStatusDbInspectError"); } expect(out.stdoutText).toBe( `Linked Project:\n Org: acme (org_1)\n Project: My Project (${LINKED_PLAIN_REF})\n`, @@ -1807,7 +1869,7 @@ content_path = "./supabase/templates/password_changed_notification.html" writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { name: "Parent Project" }); return Effect.gen(function* () { yield* legacyStatus(flags({ overrideName: ["api.url=linked_project_ref"] })); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); // `values` (the override-renamed field) spreads LAST over // `legacyLinkedStateGoFields`, so the API URL — not the branch ref — // is what ends up under this key. @@ -1999,7 +2061,7 @@ content_path = "./supabase/templates/password_changed_notification.html" writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { name: "Parent Project" }); return Effect.gen(function* () { yield* legacyStatus(flags()); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.linked_project_ref).toBe(LINKED_BRANCH_REF); expect(parsed.linked_branch).toBe("feature-x"); expect(parsed.linked_parent_project_ref).toBe(LINKED_PARENT_REF); @@ -2015,7 +2077,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* legacyStatus(flags()); - const parsed = JSON.parse(out.stdoutText) as Record<string, string>; + const parsed = decodeStatusOutput(out.stdoutText); expect(parsed.linked_project_ref).toBeUndefined(); expect(parsed.API_URL).toBe("http://127.0.0.1:54321"); }).pipe(Effect.provide(layer)); @@ -2120,7 +2182,7 @@ content_path = "./supabase/templates/password_changed_notification.html" return Effect.gen(function* () { yield* legacyStatus(flags()).pipe(withJsonErrorHandling); expect(stdio.stdout).toHaveLength(1); - const envelope = JSON.parse(stdio.stdout[0]!); + const envelope = decodeStatusError(stdio.stdout[0]!); expect(envelope._tag).toBe("Error"); expect(envelope.error.code).toBe("LegacyStatusDbInspectError"); expect(envelope.linked_project).toEqual({ @@ -2145,7 +2207,7 @@ content_path = "./supabase/templates/password_changed_notification.html" writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { name: "Parent Project" }); return Effect.gen(function* () { yield* legacyStatus(flags()).pipe(withJsonErrorHandling); - const envelope = JSON.parse(stdio.stdout[0]!); + const envelope = decodeStatusError(stdio.stdout[0]!); expect(envelope.error.code).toBe("LegacyStatusDbInspectError"); expect(envelope.linked_project).toEqual({ project_ref: LINKED_BRANCH_REF, @@ -2154,7 +2216,9 @@ content_path = "./supabase/templates/password_changed_notification.html" org_slug: "acme", org_id: "org_1", }); - expect("branch" in envelope.linked_project).toBe(false); + if (envelope.linked_project !== null && envelope.linked_project !== undefined) { + expect("branch" in envelope.linked_project).toBe(false); + } }).pipe(Effect.provide(layer)); }, ); @@ -2165,7 +2229,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, stdio } = setupFailureEnvelope({ format: "json" }); return Effect.gen(function* () { yield* legacyStatus(flags()).pipe(withJsonErrorHandling); - const envelope = JSON.parse(stdio.stdout[0]!); + const envelope = decodeStatusError(stdio.stdout[0]!); expect(envelope.error.code).toBe("LegacyStatusDbInspectError"); expect("linked_project" in envelope).toBe(true); expect(envelope.linked_project).toBeNull(); @@ -2185,7 +2249,7 @@ content_path = "./supabase/templates/password_changed_notification.html" return Effect.gen(function* () { yield* legacyStatus(flags()).pipe(withJsonErrorHandling); expect(stdio.stdout).toHaveLength(1); - const event = JSON.parse(stdio.stdout[0]!); + const event = decodeStatusStreamError(stdio.stdout[0]!); expect(event.type).toBe("error"); expect(event.error.code).toBe("LegacyStatusDbInspectError"); expect(event.linked_project).toEqual({ @@ -2225,7 +2289,7 @@ content_path = "./supabase/templates/password_changed_notification.html" return Effect.gen(function* () { yield* legacyStatus(flags()).pipe(withJsonErrorHandling); expect(stdio.stdout).toHaveLength(1); - const envelope = JSON.parse(stdio.stdout[0]!); + const envelope = decodeStatusError(stdio.stdout[0]!); expect(envelope).toEqual({ _tag: "Error", error: { diff --git a/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts index 6e18d79786..17ea781a87 100644 --- a/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this e2e test owns real subprocess lifecycle callbacks. import { execFile } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; diff --git a/apps/cli/src/legacy/commands/stop/stop.handler.ts b/apps/cli/src/legacy/commands/stop/stop.handler.ts index 95aac5aa92..c585347517 100644 --- a/apps/cli/src/legacy/commands/stop/stop.handler.ts +++ b/apps/cli/src/legacy/commands/stop/stop.handler.ts @@ -94,20 +94,13 @@ const resolveSearchProjectIdFilter = Effect.fn("legacy.stop.resolveSearchProject // discarded); it gives `stop` the same partial-but-growing config validation // coverage `status` already has (`status.handler.ts`), rather than a one-off // re-implementation. - yield* Effect.try({ - try: () => - legacyResolveLocalConfigValues( - context.config, - context.hostname, - cliConfig.workdir, - context.projectEnvValues, - context.loaded?.document, - ), - catch: (cause) => - new LegacyStopConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + yield* legacyResolveLocalConfigValues( + context.config, + context.hostname, + cliConfig.workdir, + context.projectEnvValues, + context.loaded?.document, + ).pipe(Effect.mapError((cause) => new LegacyStopConfigLoadError({ message: cause.message }))); return context.projectId; }, @@ -137,14 +130,12 @@ export const legacyStop = Effect.fn("legacy.stop")(function* (flags: LegacyStopF // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` // must reject too, not just `--all`/`--all=true`. if (Option.isSome(flags.projectId) && Option.isSome(flags.all)) { - return yield* Effect.fail( - new LegacyStopMutuallyExclusiveError({ - // The group name keeps declaration order, - // but the "were all set" list is sorted. - message: - "if any flags in the group [project-id all] are set none of the others can be; [all project-id] were all set", - }), - ); + return yield* new LegacyStopMutuallyExclusiveError({ + // The group name keeps declaration order, + // but the "were all set" list is sorted. + message: + "if any flags in the group [project-id all] are set none of the others can be; [all project-id] were all set", + }); } const searchProjectIdFilter = yield* resolveSearchProjectIdFilter(flags, cliConfig); diff --git a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts index fcf100e551..3ebcf89e15 100644 --- a/apps/cli/src/legacy/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/legacy/commands/stop/stop.integration.test.ts @@ -1,9 +1,20 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { basename, join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { + ConfigProvider, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + PlatformError, + Sink, + Stream, +} from "effect"; +import * as Formatter from "effect/Formatter"; import { ChildProcessSpawner } from "effect/unstable/process"; import { vi } from "vitest"; @@ -14,10 +25,20 @@ import { useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyDebugFlag } from "../../../shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyStop } from "./stop.handler.ts"; import type { LegacyStopFlags } from "./stop.command.ts"; const tempRoot = useLegacyTempWorkdir("supabase-stop-int-"); +const fixturePath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const pendingWrites = new Map< + string, + Array<{ readonly path: string; readonly contents: string }> +>(); +const pendingFileWrites: Array<{ readonly path: string; readonly contents: string }> = []; +const pendingDirectories = new Set<string>(); +const join = (first: string, ...rest: ReadonlyArray<string>) => fixturePath.join(first, ...rest); +const basename = (path: string) => fixturePath.basename(path); function flags(overrides: Partial<LegacyStopFlags> = {}): LegacyStopFlags { return { @@ -30,15 +51,59 @@ function flags(overrides: Partial<LegacyStopFlags> = {}): LegacyStopFlags { } function writeConfig(workdir: string, projectId: string) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), `project_id = "${projectId}"\n`); + writeFile(workdir, "config.toml", `project_id = "${projectId}"\n`); } function writeEnvFile(workdir: string, fileName: ".env" | ".env.local", contents: string) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, fileName), contents); + writeFile(workdir, fileName, contents); +} + +function writeFile(workdir: string, fileName: string, contents: string) { + const path = fixturePath.join(workdir, "supabase", fileName); + const writes = pendingWrites.get(workdir) ?? []; + writes.push({ path, contents }); + pendingWrites.set(workdir, writes); +} + +function mkdirSync(path: string, _options?: { readonly recursive?: boolean }) { + queueDirectory(path); +} + +function writeFileSync(path: string, contents: string) { + pendingFileWrites.push({ path, contents }); +} + +function queueDirectory(path: string) { + pendingDirectories.add(path); +} + +function flushFixtureWrites() { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const path of pendingDirectories) { + yield* fs.makeDirectory(path, { recursive: true }); + } + pendingDirectories.clear(); + for (const writes of pendingWrites.values()) { + for (const write of writes) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + } + pendingWrites.clear(); + for (const write of pendingFileWrites) { + yield* fs.makeDirectory(fixturePath.dirname(write.path), { recursive: true }); + yield* fs.writeFileString(write.path, write.contents); + } + pendingFileWrites.length = 0; + }); +} + +function existsPath(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)); + }); } interface SpawnRecord { @@ -92,25 +157,21 @@ function mockRoutedContainerCliSpawner( spawned.push({ command: cmd, args }); if (opts.dockerMissing === true && cmd === "docker") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "docker not found", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "docker not found", + }); } if (opts.failSpawnFor?.(args) === true) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } const encoder = new TextEncoder(); @@ -187,6 +248,7 @@ interface SetupOpts { readonly workdir?: string; /** `--debug` — gates `legacyDockerRemoveAll`'s `Pruned …:` stderr reports. */ readonly debug?: boolean; + readonly env?: Readonly<Record<string, string>>; } function setup(opts: SetupOpts = {}) { @@ -204,9 +266,16 @@ function setup(opts: SetupOpts = {}) { dockerMissing: opts.dockerMissing, failSpawnFor: opts.failSpawnFor, }); + const configProvider = ConfigProvider.fromEnv({ + env: opts.env ?? {}, + preserveEmptyStrings: true, + }); const layer = Layer.mergeAll( BunServices.layer, + Layer.effectDiscard(flushFixtureWrites().pipe(Effect.provide(BunServices.layer))), + ConfigProvider.layer(configProvider), + makeLegacyViperEnvLayer(configProvider), out.layer, cliConfig, telemetry.layer, @@ -280,8 +349,8 @@ describe("legacy stop integration", () => { writeFileSync(join(unmatchedDir, "secret-0"), "unrelated project's secret"); return Effect.gen(function* () { yield* legacyStop(flags()); - expect(existsSync(matchedDir)).toBe(false); - expect(existsSync(unmatchedDir)).toBe(true); + expect(yield* existsPath(matchedDir)).toBe(false); + expect(yield* existsPath(unmatchedDir)).toBe(true); }).pipe(Effect.provide(layer)); }, ); @@ -323,8 +392,8 @@ describe("legacy stop integration", () => { writeFileSync(join(wrongDir, "secret-0"), "must not be touched"); return Effect.gen(function* () { yield* legacyStop(flags({ all: Option.some(true) })); - expect(existsSync(correctDir)).toBe(false); - expect(existsSync(wrongDir)).toBe(true); + expect(yield* existsPath(correctDir)).toBe(false); + expect(yield* existsPath(wrongDir)).toBe(true); }).pipe(Effect.provide(layer)); }, ); @@ -473,9 +542,12 @@ describe("legacy stop integration", () => { }); it.live("prefers ambient SUPABASE_PROJECT_ID over supabase/.env", () => { - const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); + const { layer, child } = setup({ + configuredProjectId: "toml-project", + route: defaultRoute(), + env: { SUPABASE_PROJECT_ID: "ambient-project" }, + }); writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); - process.env["SUPABASE_PROJECT_ID"] = "ambient-project"; return Effect.gen(function* () { yield* legacyStop(flags()); const psCall = child.spawned.find((s) => s.args[0] === "ps"); @@ -487,10 +559,7 @@ describe("legacy stop integration", () => { "--format", '{{.ID}}\t{{.Names}}\t{{.Label "com.supabase.cli.workdir"}}', ]); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => delete process.env["SUPABASE_PROJECT_ID"])), - ); + }).pipe(Effect.provide(layer)); }); it.live( @@ -578,8 +647,8 @@ describe("legacy stop integration", () => { const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(Formatter.formatJson(exit.cause)).toContain( `failed to change workdir: chdir ${missingWorkdir}: no such file or directory`, ); } @@ -595,8 +664,8 @@ describe("legacy stop integration", () => { const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopWorkdirError"); - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopWorkdirError"); + expect(Formatter.formatJson(exit.cause)).toContain( `failed to change workdir: chdir ${filePath}: not a directory`, ); } @@ -612,7 +681,7 @@ describe("legacy stop integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -629,7 +698,7 @@ describe("legacy stop integration", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopMutuallyExclusiveError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -752,7 +821,7 @@ describe("legacy stop integration", () => { const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -781,7 +850,7 @@ project_id = "aaaaaaaaaaaaaaaaaaaa" const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -806,7 +875,7 @@ project_id = "short" const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopConfigLoadError"); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -890,8 +959,10 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopConfigLoadError"); - expect(JSON.stringify(exit.cause)).toContain("Postgres version 12.x is unsupported"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain( + "Postgres version 12.x is unsupported", + ); } expect(child.spawned).toEqual([]); }).pipe(Effect.provide(layer)); @@ -946,7 +1017,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopContainerError"); } }).pipe(Effect.provide(layer)); }); @@ -973,9 +1044,9 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopContainerError"); } - expect(existsSync(stagedDir)).toBe(true); + expect(yield* existsPath(stagedDir)).toBe(true); }).pipe(Effect.provide(layer)); }, ); @@ -993,7 +1064,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopContainerError"); } }).pipe(Effect.provide(layer)); }); @@ -1017,7 +1088,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopContainerError"); } }).pipe(Effect.provide(layer)); }, @@ -1035,7 +1106,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopContainerPruneError"); } }).pipe(Effect.provide(layer)); }); @@ -1052,7 +1123,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopVolumePruneError"); } }).pipe(Effect.provide(layer)); }); @@ -1069,7 +1140,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopNetworkPruneError"); } }).pipe(Effect.provide(layer)); }); @@ -1092,7 +1163,7 @@ enabled = true return Effect.gen(function* () { const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); - expect(existsSync(matchedDir)).toBe(false); + expect(yield* existsPath(matchedDir)).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -1108,7 +1179,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopListError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopListError"); } }).pipe(Effect.provide(layer)); }); @@ -1204,7 +1275,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopContainerPruneError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopContainerPruneError"); } }).pipe(Effect.provide(layer)); }); @@ -1219,7 +1290,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags({ noBackup: true }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopVolumePruneError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopVolumePruneError"); } }).pipe(Effect.provide(layer)); }); @@ -1234,7 +1305,7 @@ enabled = true const exit = yield* Effect.exit(legacyStop(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyStopNetworkPruneError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyStopNetworkPruneError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts index 9349c7f490..70b99ff1ab 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.command.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Formatter, Layer, Option } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -17,6 +17,7 @@ import { } from "../../../../../tests/helpers/mocks.ts"; import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; import { legacyStorageCommand } from "../storage.command.ts"; // `--jobs` is a pflag-style uint: a negative value fails @@ -50,6 +51,7 @@ function setup(args: ReadonlyArray<string>) { mockProcessControl().layer, mockTty({ stdinIsTty: false, stdoutIsTty: false }), mockAnalytics().layer, + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true })), Layer.succeed( TelemetryRuntime, TelemetryRuntime.of({ @@ -100,10 +102,12 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () expect(Option.isSome(failure)).toBe(true); // The parse failure must never reach the handler: neither the // experimental gate nor the mutex check may fire. - expect(JSON.stringify(exit.cause)).not.toContain( + expect(Formatter.formatJson(exit.cause)).not.toContain( "must set the --experimental flag to run this command", ); - expect(JSON.stringify(exit.cause)).not.toContain("LegacyStorageMutuallyExclusiveFlags"); + expect(Formatter.formatJson(exit.cause)).not.toContain( + "LegacyStorageMutuallyExclusiveFlags", + ); // `normalizeCause` is the exact rendering path `runCli` uses for // parse failures — the user-visible line must be pflag's message, // byte-identical, with no `Invalid value for flag --jobs:` wrapper. @@ -173,7 +177,7 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).not.toContain( + expect(Formatter.formatJson(exit.cause)).not.toContain( "must set the --experimental flag to run this command", ); expect(normalizeCause(exit.cause).message).toBe(message); @@ -194,7 +198,7 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "must set the --experimental flag to run this command", ); } @@ -215,7 +219,7 @@ describe("legacy storage cp --jobs negative rejection (command-tree wiring)", () const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).not.toContain("MissingArgument"); + expect(Formatter.formatJson(exit.cause)).not.toContain("MissingArgument"); expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', ); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts b/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts index aa1c623753..580a5893f2 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.handler.ts @@ -1,5 +1,3 @@ -import * as nodePath from "node:path"; - import type { ProjectConfig } from "@supabase/config"; import { Effect, FileSystem, Option, Path, Stream } from "effect"; import type { PlatformError } from "effect/PlatformError"; @@ -30,6 +28,17 @@ import { legacyGoUrlParse, legacySplitBucketPrefix, } from "../../../shared/legacy-storage-url.ts"; + +const legacyStorageCauseText = (value: unknown): string => { + if (value instanceof Error) return value.message; + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; import { legacyConnectStorageGateway, legacyLoadStorageConfig } from "../storage.frame.ts"; import { LegacyStorageConfigError } from "../../../shared/legacy-storage-credentials.errors.ts"; import { @@ -86,12 +95,10 @@ export const legacyStorageCp = Effect.fn("legacy.storage.cp")(function* ( // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && flags.local) { - return yield* Effect.fail( - new LegacyStorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }), - ); + return yield* new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }); } const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); @@ -177,6 +184,13 @@ function absLocal(path: Path.Path, cwd: string, p: string): string { return path.isAbsolute(p) ? p : path.join(cwd, p); } +/** Return the basename of a slash-delimited remote storage key. */ +function remoteObjectBasename(remotePath: string): string { + const withoutTrailingSlashes = remotePath.replace(/\/+$/, ""); + const separator = withoutTrailingSlashes.lastIndexOf("/"); + return separator === -1 ? withoutTrailingSlashes : withoutTrailingSlashes.slice(separator + 1); +} + /** Write a stream chunk fully to the open file handle. */ const writeChunk = (handle: FileSystem.File, chunk: Uint8Array) => handle.writeAll(chunk); @@ -196,7 +210,7 @@ const downloadSingle = ( Effect.mapError( (cause) => new LegacyStorageFileError({ - message: `failed to create file: ${String(cause.cause ?? cause)}`, + message: `failed to create file: ${legacyStorageCauseText(cause.cause ?? cause)}`, }), ), ); @@ -224,9 +238,7 @@ const downloadAll = ( Effect.map((i) => i.type === "Directory"), Effect.orElseSucceed(() => false), ); - const localPath = isDir - ? path.join(localPath0, nodePath.posix.basename(remotePath)) - : localPath0; + const localPath = isDir ? path.join(localPath0, remoteObjectBasename(remotePath)) : localPath0; const tasks: Array<{ objectPath: string; dstPath: string; isDir: boolean }> = []; // Capture the walk error as a value rather than failing on it immediately: @@ -271,7 +283,7 @@ const downloadAll = ( Effect.mapError( (cause) => new LegacyStorageFileError({ - message: `failed to create file: ${String(cause.cause ?? cause)}`, + message: `failed to create file: ${legacyStorageCauseText(cause.cause ?? cause)}`, }), ), ); @@ -290,7 +302,7 @@ const downloadAll = ( // the pass above (the job queue's first error); the rare walk-error + // download-error pair is collapsed to whichever fails first. if (iterError !== undefined) { - return yield* Effect.fail(iterError); + return yield* iterError; } }); @@ -299,7 +311,7 @@ const makeDirIfNotExist = (fs: FileSystem.FileSystem, dir: string) => Effect.mapError( (cause) => new LegacyStorageFileError({ - message: `failed to mkdir: ${String(cause.cause ?? cause)}`, + message: `failed to mkdir: ${legacyStorageCauseText(cause.cause ?? cause)}`, }), ), ); @@ -348,7 +360,7 @@ const uploadAll = (ctx: UploadCtx, remotePath: string, localPath: string, jobs: let dirExists = false; let fileExists = false; if (noSlash.length > 0) { - const base = nodePath.posix.basename(noSlash); + const base = ctx.path.basename(noSlash); yield* legacyIterateStoragePaths(ctx.gateway, ctx.output, noSlash, (objectName) => Effect.sync(() => { if (objectName === base) fileExists = true; @@ -414,7 +426,7 @@ const autoCreateAndRetry = ( const [bucket, prefix] = legacySplitBucketPrefix(dstPath); // Go only auto-creates when a prefix follows the bucket (`cp.go:154`). if (prefix.length === 0) { - return yield* Effect.fail(original); + return yield* original; } const props = yield* bucketAutoCreateProps(ctx, bucket); yield* ctx.gateway.createBucket(bucket, props); @@ -485,7 +497,7 @@ const walkUploadDir = ( // afero.Walk uses Lstat (no-follow); a symlink is not regular → skipped. const isSymlink = yield* fs.readLink(abs).pipe( Effect.as(true), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); if (isSymlink) continue; const info = yield* fs.stat(abs); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts index 5a43928e43..a8c4e12ce3 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.integration.test.ts @@ -1,8 +1,7 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Layer, Option } from "effect"; +import * as EffectPath from "effect/Path"; import { setupLegacyStorage } from "../../../../../tests/helpers/legacy-storage.ts"; import { @@ -15,6 +14,27 @@ import type { LegacyStorageCpFlags } from "./cp.command.ts"; const BUCKET = "/storage/v1/bucket"; const OBJECT = (p: string) => `/storage/v1/object/${p}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const testPath = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); +const windowsRemoteKeyBasenameLayer = Layer.effect( + EffectPath.Path, + Effect.gen(function* () { + const hostPath = yield* EffectPath.Path.pipe(Effect.provide(BunPath.layer)); + const windowsPath = yield* EffectPath.Path.pipe(Effect.provide(BunPath.layerWin32)); + return { ...hostPath, basename: windowsPath.basename }; + }), +); + +const readText = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(pathname); + }).pipe(Effect.provide(BunServices.layer)); + +const exists = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(pathname); + }).pipe(Effect.provide(BunServices.layer)); function cpFlags(opts: { src: string; @@ -50,15 +70,15 @@ describe("legacy storage cp", () => { const tmp = useLegacyTempWorkdir("supabase-storage-cp-"); it.live("uploads a single local file with a sniffed content-type", () => { - writeFileSync(join(tmp.current, "readme.md"), "hello world"); const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "readme.md": "hello world" }, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + cpFlags({ src: testPath.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const upload = requests.find((r) => r.url.includes(OBJECT("private/readme.md"))); @@ -71,16 +91,16 @@ describe("legacy storage cp", () => { }); it.live("honors --content-type and --cache-control on upload", () => { - writeFileSync(join(tmp.current, "data.bin"), "hello"); const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "data.bin": "hello" }, routes: [{ method: "POST", match: OBJECT("private/data.bin"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( cpFlags({ - src: join(tmp.current, "data.bin"), + src: testPath.join(tmp.current, "data.bin"), dst: "ss:///private/data.bin", contentType: "application/custom", cacheControl: "max-age=60", @@ -94,11 +114,10 @@ describe("legacy storage cp", () => { }); it.live("recursively uploads a directory, auto-creating a missing bucket", () => { - mkdirSync(join(tmp.current, "upload"), { recursive: true }); - writeFileSync(join(tmp.current, "upload", "readme.md"), "hello"); const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "upload/readme.md": "hello" }, routes: [ // first upload → bucket missing { @@ -115,7 +134,7 @@ describe("legacy storage cp", () => { }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "upload"), dst: "ss://", recursive: true }), + cpFlags({ src: testPath.join(tmp.current, "upload"), dst: "ss://", recursive: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); // Recursive uploads set x-upsert; bucket auto-created then upload retried. @@ -129,7 +148,7 @@ describe("legacy storage cp", () => { }); it.live("downloads a single remote object to a new local file", () => { - const dst = join(tmp.current, "out.md"); + const dst = testPath.join(tmp.current, "out.md"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -141,16 +160,16 @@ describe("legacy storage cp", () => { Effect.exit, ); expect(Exit.isSuccess(exit)).toBe(true); - expect(readFileSync(dst, "utf8")).toBe("downloaded-bytes"); + expect(yield* readText(dst)).toBe("downloaded-bytes"); }); }); it.live("refuses to overwrite an existing local file on a single download", () => { - const dst = join(tmp.current, "exists.md"); - writeFileSync(dst, "original"); + const dst = testPath.join(tmp.current, "exists.md"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "exists.md": "original" }, routes: [{ method: "GET", match: OBJECT("private/readme.md"), rawBody: "new" }], }); return Effect.gen(function* () { @@ -159,14 +178,14 @@ describe("legacy storage cp", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("failed to create file"); + expect(Formatter.formatJson(exit)).toContain("failed to create file"); // The existing file is untouched. - expect(readFileSync(dst, "utf8")).toBe("original"); + expect(yield* readText(dst)).toBe("original"); }); }); it.live("recursively downloads nested objects, creating parent dirs", () => { - const dst = join(tmp.current, "dl"); + const dst = testPath.join(tmp.current, "dl"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -195,17 +214,17 @@ describe("legacy storage cp", () => { cpFlags({ src: "ss:///private/", dst, recursive: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - expect(readFileSync(join(dst, "a.txt"), "utf8")).toBe("a-content"); - expect(readFileSync(join(dst, "folder", "b.txt"), "utf8")).toBe("b-content"); + expect(yield* readText(testPath.join(dst, "a.txt"))).toBe("a-content"); + expect(yield* readText(testPath.join(dst, "folder", "b.txt"))).toBe("b-content"); }); }); it.live("recursively downloads into an existing directory (nests under the remote base)", () => { - const dst = join(tmp.current, "existing"); - mkdirSync(dst, { recursive: true }); + const dst = testPath.join(tmp.current, "existing"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "existing/.keep": "" }, routes: [ { method: "POST", match: LIST("private"), body: [{ name: "a.txt", id: "ai" }] }, { method: "GET", match: OBJECT("private/a.txt"), rawBody: "a" }, @@ -217,12 +236,41 @@ describe("legacy storage cp", () => { ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); // Existing dir → nest under base("/private/") = "private". - expect(readFileSync(join(dst, "private", "a.txt"), "utf8")).toBe("a"); + expect(yield* readText(testPath.join(dst, "private", "a.txt"))).toBe("a"); + }); + }); + + it.live("preserves backslashes in remote object keys on Windows path semantics", () => { + const dst = testPath.join(tmp.current, "existing-backslash"); + const remotePrefix = "folder\\object/"; + const { layer } = setupLegacyStorage(tmp.current, { + toml: 'project_id = "test"\n', + local: true, + files: { "existing-backslash/.keep": "" }, + routes: [ + { + method: "POST", + match: LIST("private"), + when: (body) => prefixOf(body) === remotePrefix, + body: [{ name: "child.txt", id: "child" }], + }, + { method: "GET", match: "child.txt", rawBody: "child-content" }, + ], + }); + return Effect.gen(function* () { + const exit = yield* legacyStorageCp( + cpFlags({ src: `ss:///private/${remotePrefix}`, dst, recursive: true }), + ).pipe(Effect.provide(Layer.mergeAll(layer, windowsRemoteKeyBasenameLayer)), Effect.exit); + expect(Exit.isSuccess(exit)).toBe(true); + expect(yield* exists(testPath.join(dst, "object", "child.txt"))).toBe(false); + expect(yield* readText(testPath.join(dst, "folder\\object", "child.txt"))).toBe( + "child-content", + ); }); }); it.live("creates a directory for an empty bucket on recursive download", () => { - const dst = join(tmp.current, "dl-empty"); + const dst = testPath.join(tmp.current, "dl-empty"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -238,17 +286,15 @@ describe("legacy storage cp", () => { ); expect(Exit.isSuccess(exit)).toBe(true); // Empty bucket reported as "empty/" → mkdir under the destination. - expect(existsSync(join(dst, "empty"))).toBe(true); + expect(yield* exists(testPath.join(dst, "empty"))).toBe(true); }); }); it.live("recursively uploads a nested subdirectory", () => { - mkdirSync(join(tmp.current, "tree", "sub"), { recursive: true }); - writeFileSync(join(tmp.current, "tree", "top.txt"), "t"); - writeFileSync(join(tmp.current, "tree", "sub", "nested.txt"), "n"); const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "tree/top.txt": "t", "tree/sub/nested.txt": "n" }, routes: [ { method: "POST", match: LIST("private"), body: [{ name: "dir", id: null }] }, { method: "POST", match: OBJECT("private/dir/tree/top.txt"), body: {} }, @@ -257,7 +303,11 @@ describe("legacy storage cp", () => { }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "tree"), dst: "ss:///private/dir/", recursive: true }), + cpFlags({ + src: testPath.join(tmp.current, "tree"), + dst: "ss:///private/dir/", + recursive: true, + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.url.includes(OBJECT("private/dir/tree/sub/nested.txt")))).toBe( @@ -267,11 +317,10 @@ describe("legacy storage cp", () => { }); it.live("auto-creates a bucket using its config from supabase/config.toml", () => { - mkdirSync(join(tmp.current, "media"), { recursive: true }); - writeFileSync(join(tmp.current, "media", "a.png"), "x"); const { layer, requests } = setupLegacyStorage(tmp.current, { toml: "[storage.buckets.media]\npublic = true\n", local: true, + files: { "media/a.png": "x" }, routes: [ { method: "POST", @@ -285,7 +334,7 @@ describe("legacy storage cp", () => { }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "media"), dst: "ss://", recursive: true }), + cpFlags({ src: testPath.join(tmp.current, "media"), dst: "ss://", recursive: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const create = requests.find( @@ -305,15 +354,19 @@ describe("legacy storage cp", () => { }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: "ss:///private/empty/", dst: join(tmp.current, "dl"), recursive: true }), + cpFlags({ + src: "ss:///private/empty/", + dst: testPath.join(tmp.current, "dl"), + recursive: true, + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Object not found: /private/empty/"); + expect(Formatter.formatJson(exit)).toContain("Object not found: /private/empty/"); }); }); it.live("runs already-queued downloads when the walk errors partway (errors.Join parity)", () => { - const dst = join(tmp.current, "partial"); + const dst = testPath.join(tmp.current, "partial"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -346,7 +399,7 @@ describe("legacy storage cp", () => { // The queued a.txt download runs (file written) before the walk error // surfaces — the command still fails. expect(Exit.isFailure(exit)).toBe(true); - expect(readFileSync(join(dst, "a.txt"), "utf8")).toBe("a-content"); + expect(yield* readText(testPath.join(dst, "a.txt"))).toBe("a-content"); }); }); @@ -361,7 +414,7 @@ describe("legacy storage cp", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Copying between buckets is not supported"); + expect(Formatter.formatJson(exit)).toContain("Copying between buckets is not supported"); }); }); @@ -376,7 +429,7 @@ describe("legacy storage cp", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - const json = JSON.stringify(exit); + const json = Formatter.formatJson(exit); expect(json).toContain("Unsupported operation"); expect(json).toContain("to copy between local directories"); }); @@ -393,7 +446,7 @@ describe("legacy storage cp", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - const json = JSON.stringify(exit); + const json = Formatter.formatJson(exit); expect(json).toContain("failed to parse src url"); expect(json).toContain("missing protocol scheme"); expect(requests).toHaveLength(0); @@ -407,42 +460,46 @@ describe("legacy storage cp", () => { }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "missing"), dst: "ss:///private", recursive: true }), + cpFlags({ + src: testPath.join(tmp.current, "missing"), + dst: "ss:///private", + recursive: true, + }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }); }); it.live("emits an { uploaded, downloaded } result in json mode", () => { - writeFileSync(join(tmp.current, "readme.md"), "hello"); const { layer, out } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, format: "json", + files: { "readme.md": "hello" }, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + cpFlags({ src: testPath.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const success = out.messages.find((m) => m.type === "success"); const uploaded = success?.data?.["uploaded"] as Array<{ to: string }>; expect(uploaded?.[0]?.to).toBe("/private/readme.md"); - expect(existsSync(join(tmp.current, "readme.md"))).toBe(true); + expect(yield* exists(testPath.join(tmp.current, "readme.md"))).toBe(true); }); }); it.live("targets the linked project's Storage host and flushes telemetry on upload", () => { - writeFileSync(join(tmp.current, "readme.md"), "hello world"); const { layer, requests, telemetry, linkedCache } = setupLegacyStorage(tmp.current, { + files: { "readme.md": "hello world" }, // No `--local`, so the linked path resolves the ref + service-role key. routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( cpFlags({ - src: join(tmp.current, "readme.md"), + src: testPath.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md", local: false, }), @@ -461,13 +518,16 @@ describe("legacy storage cp", () => { // `opts.projectRef` (the fake's own fallback) is left at its default // (LEGACY_VALID_REF) — the flag must win over it and drive the gateway host. const FLAG_REF = "flagflagflagflagflag"; - writeFileSync(join(tmp.current, "readme.md"), "hello world"); const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { + files: { "readme.md": "hello world" }, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp({ - ...cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + ...cpFlags({ + src: testPath.join(tmp.current, "readme.md"), + dst: "ss:///private/readme.md", + }), local: false, projectRef: Option.some(FLAG_REF), }).pipe(Effect.provide(layer), Effect.exit); @@ -481,19 +541,22 @@ describe("legacy storage cp", () => { it.live("rejects --project-ref combined with --local", () => { const FLAG_REF = "flagflagflagflagflag"; - writeFileSync(join(tmp.current, "readme.md"), "hello world"); const { layer, requests, linkedCache } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "readme.md": "hello world" }, }); return Effect.gen(function* () { const exit = yield* legacyStorageCp({ - ...cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + ...cpFlags({ + src: testPath.join(tmp.current, "readme.md"), + dst: "ss:///private/readme.md", + }), local: true, projectRef: Option.some(FLAG_REF), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", ); expect(requests).toHaveLength(0); @@ -502,10 +565,10 @@ describe("legacy storage cp", () => { }); it.live("propagates a non-200 from the gateway on upload", () => { - writeFileSync(join(tmp.current, "readme.md"), "hello"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "readme.md": "hello" }, routes: [ { method: "POST", @@ -517,24 +580,24 @@ describe("legacy storage cp", () => { }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + cpFlags({ src: testPath.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Error status 503"); + expect(Formatter.formatJson(exit)).toContain("Error status 503"); }); }); it.live("emits the uploaded result as a streamed event in stream-json mode", () => { - writeFileSync(join(tmp.current, "readme.md"), "hello"); const { layer, out } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, format: "stream-json", + files: { "readme.md": "hello" }, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( - cpFlags({ src: join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), + cpFlags({ src: testPath.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); const success = out.messages.find((m) => m.type === "success"); @@ -544,16 +607,16 @@ describe("legacy storage cp", () => { }); it.live("clamps --jobs below 1 to a single worker", () => { - writeFileSync(join(tmp.current, "readme.md"), "hello"); const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + files: { "readme.md": "hello" }, routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], }); return Effect.gen(function* () { const exit = yield* legacyStorageCp( cpFlags({ - src: join(tmp.current, "readme.md"), + src: testPath.join(tmp.current, "readme.md"), dst: "ss:///private/readme.md", jobs: 0, }), @@ -564,7 +627,7 @@ describe("legacy storage cp", () => { }); it.live("downloads nested objects in parallel with --jobs 2", () => { - const dst = join(tmp.current, "dl-parallel"); + const dst = testPath.join(tmp.current, "dl-parallel"); const { layer } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -586,8 +649,8 @@ describe("legacy storage cp", () => { cpFlags({ src: "ss:///private/", dst, recursive: true, jobs: 2 }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - expect(readFileSync(join(dst, "a.txt"), "utf8")).toBe("a-content"); - expect(readFileSync(join(dst, "b.txt"), "utf8")).toBe("b-content"); + expect(yield* readText(testPath.join(dst, "a.txt"))).toBe("a-content"); + expect(yield* readText(testPath.join(dst, "b.txt"))).toBe("b-content"); }); }); }); diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts index c66f82c981..e8d0cfd2bd 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and creates unique remote resources. import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/storage/cp/cp.upload.ts b/apps/cli/src/legacy/commands/storage/cp/cp.upload.ts index 108ec55229..9b2b28dca9 100644 --- a/apps/cli/src/legacy/commands/storage/cp/cp.upload.ts +++ b/apps/cli/src/legacy/commands/storage/cp/cp.upload.ts @@ -1,5 +1,3 @@ -import * as nodePath from "node:path"; - import { legacySplitBucketPrefix, legacyStorageIsDir } from "../../../shared/legacy-storage-url.ts"; /** @@ -24,6 +22,26 @@ export interface LegacyUploadDstPathInput { readonly fileExists: boolean; } +const legacyPosixJoin = (...segments: ReadonlyArray<string>): string => { + const joined = segments.filter((segment) => segment.length > 0).join("/"); + if (joined.length === 0) return "."; + const absolute = joined.startsWith("/"); + const trailing = joined.endsWith("/"); + const parts: string[] = []; + for (const part of joined.split("/")) { + if (part.length === 0 || part === ".") continue; + if (part === "..") { + if (parts.length > 0 && parts.at(-1) !== "..") parts.pop(); + else if (!absolute) parts.push(part); + continue; + } + parts.push(part); + } + let result = `${absolute ? "/" : ""}${parts.join("/")}`; + if (result.length === 0) result = absolute ? "/" : "."; + return trailing && result !== "/" ? `${result}/` : result; +}; + /** * Resolve the remote destination key for one walked file (`cp.go:135-148`): * - single file (`relPath === "."`): append the file name only when the @@ -40,13 +58,13 @@ export function legacyResolveUploadDstPath(input: LegacyUploadDstPathInput): str if (input.relPath === ".") { const [, prefix] = legacySplitBucketPrefix(dstPath); if (legacyStorageIsDir(prefix) || (input.dirExists && !input.fileExists)) { - dstPath = nodePath.posix.join(dstPath, input.fileName); + dstPath = legacyPosixJoin(dstPath, input.fileName); } return dstPath; } if (input.baseName !== "." && (input.dirExists || input.noSlash.length === 0)) { - dstPath = nodePath.posix.join(dstPath, input.baseName); + dstPath = legacyPosixJoin(dstPath, input.baseName); } - const relPosix = input.relPath.split(nodePath.sep).join(nodePath.posix.sep); - return nodePath.posix.join(dstPath, relPosix); + const relPosix = input.relPath.split(/[\\/]/u).join("/"); + return legacyPosixJoin(dstPath, relPosix); } diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts b/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts index 6584408df2..d6bebdb9d3 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.handler.ts @@ -37,12 +37,10 @@ export const legacyStorageLs = Effect.fn("legacy.storage.ls")(function* ( // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && flags.local) { - return yield* Effect.fail( - new LegacyStorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }), - ); + return yield* new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }); } // Routing reads the `--local` value (Go `storage.go:21-32`): local clears the diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts index 03385487d2..80cdd00f18 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Formatter, Option } from "effect"; import { LEGACY_VALID_REF } from "../../../../../tests/helpers/legacy-mocks.ts"; import { setupLegacyStorage } from "../../../../../tests/helpers/legacy-storage.ts"; @@ -160,7 +160,7 @@ describe("legacy storage ls", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("URL must match pattern ss:///bucket/[prefix]"); + expect(Formatter.formatJson(exit)).toContain("URL must match pattern ss:///bucket/[prefix]"); expect(requests).toHaveLength(0); }); }); @@ -176,7 +176,7 @@ describe("legacy storage ls", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - const json = JSON.stringify(exit); + const json = Formatter.formatJson(exit); expect(json).toContain("failed to parse storage url"); expect(json).toContain("missing protocol scheme"); }); @@ -191,7 +191,7 @@ describe("legacy storage ls", () => { return Effect.gen(function* () { const exit = yield* legacyStorageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Error status 503"); + expect(Formatter.formatJson(exit)).toContain("Error status 503"); }); }); @@ -253,7 +253,7 @@ describe("legacy storage ls", () => { projectRef: Option.some(FLAG_REF), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", ); expect(requests).toHaveLength(0); diff --git a/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts index b678ceb7df..e31aba0cb3 100644 --- a/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts +++ b/apps/cli/src/legacy/commands/storage/ls/ls.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and creates unique remote resources. import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts b/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts index 4f5f24910b..b6a9fab483 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.handler.ts @@ -1,6 +1,4 @@ -import * as nodePath from "node:path"; - -import { Effect, Option } from "effect"; +import { Effect, Option, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; @@ -38,6 +36,7 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( const telemetryState = yield* LegacyTelemetryState; const linkedProjectCache = yield* LegacyLinkedProjectCache; const resolver = yield* LegacyProjectRefResolver; + const path = yield* Path.Path; let linkedRef = ""; @@ -46,12 +45,10 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && flags.local) { - return yield* Effect.fail( - new LegacyStorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }), - ); + return yield* new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }); } const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); @@ -100,7 +97,13 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( } // Recursive fallback on `not_found`. - const moved = yield* moveStorageObjectAll(gateway, output, `${srcParsed}/`, dstParsed); + const moved = yield* moveStorageObjectAll( + gateway, + output, + path, + `${srcParsed}/`, + dstParsed, + ); if (output.format !== "text") { yield* output.success("", { message: "", moved }); } @@ -122,6 +125,7 @@ export const legacyStorageMv = Effect.fn("legacy.storage.mv")(function* ( const moveStorageObjectAll = ( gateway: LegacyStorageGateway, output: typeof Output.Service, + path: Path.Path, srcPath: string, dstPath: string, ) => @@ -144,9 +148,9 @@ const moveStorageObjectAll = ( ? objectPath.slice(srcPath.length) : objectPath; const [srcBucket, srcPrefix] = legacySplitBucketPrefix(objectPath); - const absPath = nodePath.posix.join(dstPrefix, relPath); + const absPath = path.join(dstPrefix, relPath); yield* output.raw( - `Moving object: ${objectPath} => ${nodePath.posix.join(dstPath, relPath)}\n`, + `Moving object: ${objectPath} => ${path.join(dstPath, relPath)}\n`, "stderr", ); yield* gateway.moveObject(srcBucket, srcPrefix, absPath); diff --git a/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts b/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts index 2ad925f9fe..0d63176ebc 100644 --- a/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/mv/mv.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Formatter, Option } from "effect"; import { setupLegacyStorage } from "../../../../../tests/helpers/legacy-storage.ts"; import { @@ -64,7 +64,7 @@ describe("legacy storage mv", () => { Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("You must specify an object path"); + expect(Formatter.formatJson(exit)).toContain("You must specify an object path"); expect(requests).toHaveLength(0); }); }); @@ -79,7 +79,7 @@ describe("legacy storage mv", () => { mvFlags({ src: "ss:///bucket/docs", dst: "ss:///private" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Moving between buckets is unsupported"); + expect(Formatter.formatJson(exit)).toContain("Moving between buckets is unsupported"); expect(requests).toHaveLength(0); }); }); @@ -196,7 +196,7 @@ describe("legacy storage mv", () => { mvFlags({ src: "ss:///private/a", dst: "ss:///private/b" }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("not_found"); + expect(Formatter.formatJson(exit)).toContain("not_found"); }); }); @@ -219,7 +219,7 @@ describe("legacy storage mv", () => { mvFlags({ src: "ss:///private/dir", dst: "ss:///private/other", recursive: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Object not found: /private/dir/"); + expect(Formatter.formatJson(exit)).toContain("Object not found: /private/dir/"); }); }); @@ -293,7 +293,7 @@ describe("legacy storage mv", () => { projectRef: Option.some(FLAG_REF), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", ); expect(requests).toHaveLength(0); @@ -313,7 +313,7 @@ describe("legacy storage mv", () => { mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", recursive: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Error status 503"); + expect(Formatter.formatJson(exit)).toContain("Error status 503"); }); }); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts index d6a622eb2d..8b5e3c0f79 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.handler.ts @@ -73,12 +73,10 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( // discarded on the local target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && flags.local) { - return yield* Effect.fail( - new LegacyStorageMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", - }), - ); + return yield* new LegacyStorageMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", + }); } const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); @@ -122,7 +120,7 @@ export const legacyStorageRm = Effect.fn("legacy.storage.rm")(function* ( if (!flags.recursive) { return yield* new LegacyStorageMissingFlagError(); } - const buckets = yield* gateway.listBuckets(); + const buckets = yield* gateway.listBuckets; for (const b of buckets) groups.set(b.name, [""]); } diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts index 3e9f3fcd71..ae5c90843f 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.integration.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; -import { afterEach } from "vitest"; +import { Effect, Exit, Formatter, Option } from "effect"; import { setupLegacyStorage } from "../../../../../tests/helpers/legacy-storage.ts"; import { @@ -25,10 +24,6 @@ function prefixCount(body: unknown): number { describe("legacy storage rm", () => { const tmp = useLegacyTempWorkdir("supabase-storage-rm-"); - afterEach(() => { - delete process.env["SUPABASE_YES"]; - }); - it.live("deletes multiple objects after confirmation", () => { const { layer, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', @@ -83,10 +78,10 @@ describe("legacy storage rm", () => { it.live("auto-confirms via SUPABASE_YES even without the --yes flag", () => { // viper AutomaticEnv (root.go:318-320) means `SUPABASE_YES` is equivalent to // `--yes`; the flag layer is left at its default `false` to prove the env path. - process.env["SUPABASE_YES"] = "1"; const { layer, out, requests } = setupLegacyStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, + env: { SUPABASE_YES: "1" }, routes: [{ method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }], }); return Effect.gen(function* () { @@ -149,8 +144,8 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Cannot find project ref"); - expect(JSON.stringify(exit)).not.toContain("failed to parse environment file"); + expect(Formatter.formatJson(exit)).toContain("Cannot find project ref"); + expect(Formatter.formatJson(exit)).not.toContain("failed to parse environment file"); expect(requests).toHaveLength(0); }); }, @@ -297,7 +292,7 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("You must specify a bucket to delete."); + expect(Formatter.formatJson(exit)).toContain("You must specify a bucket to delete."); expect(requests).toHaveLength(0); }); }); @@ -316,7 +311,9 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("You must specify -r flag to delete directories."); + expect(Formatter.formatJson(exit)).toContain( + "You must specify -r flag to delete directories.", + ); expect(requests).toHaveLength(0); }); }); @@ -335,7 +332,9 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("You must specify -r flag to delete directories."); + expect(Formatter.formatJson(exit)).toContain( + "You must specify -r flag to delete directories.", + ); }); }); @@ -511,7 +510,7 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Object not found: private/dir/"); + expect(Formatter.formatJson(exit)).toContain("Object not found: private/dir/"); }); }); @@ -562,7 +561,7 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Error status 500"); + expect(Formatter.formatJson(exit)).toContain("Error status 500"); }); }); @@ -582,7 +581,7 @@ describe("legacy storage rm", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain("Error status 503"); + expect(Formatter.formatJson(exit)).toContain("Error status 503"); expect(requests.some((r) => r.method === "DELETE")).toBe(false); }); }); @@ -651,7 +650,7 @@ describe("legacy storage rm", () => { projectRef: Option.some(FLAG_REF), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local)", ); expect(requests).toHaveLength(0); diff --git a/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts index 674268a2cc..aabfe4cbc8 100644 --- a/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts +++ b/apps/cli/src/legacy/commands/storage/rm/rm.live.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- this live test drives the real CLI and creates unique remote resources. import { randomUUID } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts b/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts index d9cae9fcda..1093721e5f 100644 --- a/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts +++ b/apps/cli/src/legacy/commands/storage/storage.e2e.test.ts @@ -1,7 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; @@ -17,73 +16,84 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase storage (legacy)", () => { let projectDir: string; - beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-storage-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test"\n'); - }); + beforeAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + projectDir = yield* fs.makeTempDirectory({ prefix: "supabase-storage-e2e-" }); + yield* fs.makeDirectory(path.join(projectDir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectDir, "supabase", "config.toml"), + 'project_id = "test"\n', + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); + afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - test("lists the four subcommands in --help", { timeout: E2E_TIMEOUT_MS }, async () => { - const { exitCode, stdout } = await runSupabase(["storage", "--help"], { + test("lists the four subcommands in --help", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["storage", "--help"], { entrypoint: "legacy", cwd: projectDir, - }); - expect(exitCode).toBe(0); - for (const sub of ["ls", "cp", "mv", "rm"]) { - expect(stdout).toContain(sub); - } - }); + }).then(({ exitCode, stdout }) => { + expect(exitCode).toBe(0); + for (const sub of ["ls", "cp", "mv", "rm"]) { + expect(stdout).toContain(sub); + } + }), + ); - test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, async () => { + test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, () => { // The experimental gate runs BEFORE the mutex check, so --experimental // must be set here to reach the mutex check at all — otherwise the // experimental-gate error wins (see the next test). - const { exitCode, stdout, stderr } = await runSupabase( - ["storage", "ls", "--local", "--linked", "ss:///", "--experimental"], - { entrypoint: "legacy", cwd: projectDir }, - ); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain( - "if any flags in the group [linked local] are set none of the others can be", - ); + return runSupabase(["storage", "ls", "--local", "--linked", "ss:///", "--experimental"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain( + "if any flags in the group [linked local] are set none of the others can be", + ); + }); }); - test( - "rejects storage subcommands without --experimental", - { timeout: E2E_TIMEOUT_MS }, - async () => { - // `storage` is an experimental command group; running it without - // --experimental is rejected by the experimental gate. - const { exitCode, stdout, stderr } = await runSupabase( - ["storage", "ls", "ss:///", "--local"], - { - entrypoint: "legacy", - cwd: projectDir, - }, - ); + test("rejects storage subcommands without --experimental", { timeout: E2E_TIMEOUT_MS }, () => { + // `storage` is an experimental command group; running it without + // --experimental is rejected by the experimental gate. + return runSupabase(["storage", "ls", "ss:///", "--local"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ exitCode, stdout, stderr }) => { expect(exitCode).toBe(1); expect(`${stdout}${stderr}`).toContain( "must set the --experimental flag to run this command", ); - }, - ); + }); + }); - test("accepts --local after the subcommand token", { timeout: E2E_TIMEOUT_MS }, async () => { + test("accepts --local after the subcommand token", { timeout: E2E_TIMEOUT_MS }, () => { // `--linked`/`--local` are per-leaf flags (Effect CLI requires unique // global-flag names tree-wide and `seed` owns them), so they follow the // subcommand. With --experimental it parses and passes the gate; there's no // live local stack so it fails to connect — but it must PARSE (no // "Unrecognized flag") and must NOT be blocked by the experimental gate. - const { stdout, stderr } = await runSupabase( - ["storage", "ls", "ss:///", "--local", "--experimental"], - { entrypoint: "legacy", cwd: projectDir }, - ); - const combined = `${stdout}${stderr}`; - expect(combined).not.toContain("Unrecognized flag"); - expect(combined).not.toContain("must set the --experimental flag"); + return runSupabase(["storage", "ls", "ss:///", "--local", "--experimental"], { + entrypoint: "legacy", + cwd: projectDir, + }).then(({ stdout, stderr }) => { + const combined = `${stdout}${stderr}`; + expect(combined).not.toContain("Unrecognized flag"); + expect(combined).not.toContain("must set the --experimental flag"); + }); }); }); diff --git a/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts index 63d5bf22a7..ea270470c2 100644 --- a/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/storage/storage.experimental-gate.integration.test.ts @@ -18,6 +18,7 @@ import { useLegacyTempWorkdir, } from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyExperimentalRequiredError } from "../../shared/legacy-experimental-gate.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyStorageCommand } from "./storage.command.ts"; import { LegacyStorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; @@ -54,6 +55,7 @@ function setup(args: ReadonlyArray<string>) { configDir: `${tempRoot.current}/.supabase`, tracesDir: `${tempRoot.current}/.supabase/traces`, }), + makeLegacyViperEnvLayer(), ); return { layer }; } diff --git a/apps/cli/src/legacy/commands/storage/storage.flags.unit.test.ts b/apps/cli/src/legacy/commands/storage/storage.flags.unit.test.ts index 28fbaf13d6..8919eda17a 100644 --- a/apps/cli/src/legacy/commands/storage/storage.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/storage/storage.flags.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, Formatter } from "effect"; import { legacyAssertStorageTargetsExclusive, @@ -64,7 +64,7 @@ describe("legacyStorageChangedTargetFlags", () => { }); describe("legacyAssertStorageTargetsExclusive", () => { - it("rejects passing both --linked and --local (byte-exact cobra message)", () => + it.effect("rejects passing both --linked and --local (byte-exact cobra message)", () => Effect.gen(function* () { const exit = yield* legacyAssertStorageTargetsExclusive([ "storage", @@ -73,22 +73,25 @@ describe("legacyAssertStorageTargetsExclusive", () => { "ls", ]).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - expect(JSON.stringify(exit)).toContain( + expect(Formatter.formatJson(exit)).toContain( "if any flags in the group [linked local] are set none of the others can be; [linked local] were all set", ); - })); + }), + ); - it("accepts only --local", () => + it.effect("accepts only --local", () => Effect.gen(function* () { const exit = yield* legacyAssertStorageTargetsExclusive(["storage", "--local", "ls"]).pipe( Effect.exit, ); expect(Exit.isSuccess(exit)).toBe(true); - })); + }), + ); - it("accepts neither flag", () => + it.effect("accepts neither flag", () => Effect.gen(function* () { const exit = yield* legacyAssertStorageTargetsExclusive(["storage", "ls"]).pipe(Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - })); + }), + ); }); diff --git a/apps/cli/src/legacy/commands/storage/storage.frame.ts b/apps/cli/src/legacy/commands/storage/storage.frame.ts index 903853152f..7038a568cc 100644 --- a/apps/cli/src/legacy/commands/storage/storage.frame.ts +++ b/apps/cli/src/legacy/commands/storage/storage.frame.ts @@ -5,12 +5,9 @@ import { type ProjectConfig, } from "@supabase/config"; import { Effect, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "../../shared/legacy-storage-credentials.ts"; +import { legacyResolveStorageCredentials } from "../../shared/legacy-storage-credentials.ts"; +import { LegacyLocalGatewayHttpClient } from "../../shared/legacy-local-gateway-http-client.ts"; import { legacyMakeStorageGateway, type LegacyStorageGateway, @@ -76,8 +73,7 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( /** * Resolve Storage credentials and run `body` against a freshly-built gateway, - * with the `FetchHttpClient.Fetch` override applied to the gateway calls only - * (CA-trusting for a local https gateway, plain `globalThis.fetch` otherwise). + * using the explicit local-gateway transport boundary for gateway calls only. * * The credential lookup (the `--linked` api-keys call) runs BEFORE the override * scope, so it still honors `--dns-resolver https` through the Management API @@ -97,6 +93,7 @@ export const legacyConnectStorageGateway = <E, R>( projectRef: opts.projectRef, config: opts.config, }); + const localGatewayHttpClient = yield* LegacyLocalGatewayHttpClient; const gatewayOps = Effect.gen(function* () { const gateway = yield* legacyMakeStorageGateway({ baseUrl: credentials.baseUrl, @@ -105,12 +102,7 @@ export const legacyConnectStorageGateway = <E, R>( }); return yield* body(gateway); }); - return yield* gatewayOps.pipe( - Effect.provideService( - FetchHttpClient.Fetch, - legacyStorageGatewayFetch(credentials.localKongCa), - ), - ); + return yield* localGatewayHttpClient.use(credentials.localKongCa, gatewayOps); }); /** diff --git a/apps/cli/src/legacy/commands/storage/storage.iterate.ts b/apps/cli/src/legacy/commands/storage/storage.iterate.ts index d67774a764..cf82f64bcf 100644 --- a/apps/cli/src/legacy/commands/storage/storage.iterate.ts +++ b/apps/cli/src/legacy/commands/storage/storage.iterate.ts @@ -32,7 +32,7 @@ export const legacyIterateStoragePaths = <E>( Effect.gen(function* () { const [bucket, prefix] = legacySplitBucketPrefix(remotePath); if (bucket.length === 0 || (prefix.length === 0 && !remotePath.endsWith("/"))) { - const buckets = yield* gateway.listBuckets(); + const buckets = yield* gateway.listBuckets; for (const b of buckets) { if (b.name.startsWith(bucket)) { yield* callback(`${b.name}/`); diff --git a/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts index 870403c99e..01a07001b1 100644 --- a/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts +++ b/apps/cli/src/legacy/commands/telemetry/telemetry.integration.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Formatter, Layer, Path, Schema } from "effect"; import { Command } from "effect/unstable/cli"; import { @@ -14,22 +11,44 @@ import { mockTty, processEnvLayer, } from "../../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { cliConfigLayer } from "../../../next/config/cli-config.layer.ts"; import { processControlLayer } from "../../../shared/runtime/process-control.layer.ts"; import { EventCommandExecuted } from "../../../shared/telemetry/event-catalog.ts"; import { legacyAnalyticsLayer } from "../../telemetry/legacy-analytics.layer.ts"; import { legacyTelemetryCommand } from "./telemetry.command.ts"; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-legacy-telemetry-")); +const tempRoot = useLegacyTempWorkdir("supabase-legacy-telemetry-"); + +function writeTelemetryFile(dir: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(dir, "telemetry.json"), contents); + }).pipe(Effect.provide(BunServices.layer)); +} + +function writeTelemetryConfig(dir: string, value: unknown) { + return writeTelemetryFile(dir, Formatter.formatJson(value)); } -function telemetryPath(dir: string): string { - return path.join(dir, "telemetry.json"); +function readTelemetryConfig(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const contents = yield* fs.readFileString(path.join(dir, "telemetry.json")); + return yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), + )(contents); + }).pipe(Effect.provide(BunServices.layer)); } -function readTelemetryConfig(dir: string): Record<string, unknown> { - return JSON.parse(readFileSync(telemetryPath(dir), "utf8")) as Record<string, unknown>; +function telemetryFileExists(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.exists(path.join(dir, "telemetry.json")); + }).pipe(Effect.provide(BunServices.layer)); } function setup(dir: string) { @@ -71,12 +90,8 @@ function setupWithRealAnalytics(dir: string) { Layer.provide(ttyLayer), Layer.provide(BunServices.layer), ); - const layer = Layer.mergeAll( - out.layer, - analyticsLayer, - BunServices.layer, - processControlLayer, - envLayer, + const layer = analyticsLayer.pipe( + Layer.provideMerge(Layer.mergeAll(out.layer, processControlLayer, envLayer, BunServices.layer)), ); return { out, layer }; } @@ -87,98 +102,81 @@ function legacyTestRoot() { describe("legacy telemetry integration", () => { it.live("status creates legacy telemetry.json and prints Go-style enabled output", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { out, layer } = setup(dir); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "status"]); expect(out.stdoutText).toBe("Telemetry is enabled.\n"); - expect(existsSync(telemetryPath(dir))).toBe(true); - const config = readTelemetryConfig(dir); + expect(yield* telemetryFileExists(dir)).toBe(true); + const config = yield* readTelemetryConfig(dir); expect(config.enabled).toBe(true); expect(config.schema_version).toBe(1); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); it.live("enable preserves prior identity fields and prints Go-style enabled output", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { out, layer } = setup(dir); - writeFileSync( - telemetryPath(dir), - JSON.stringify({ - enabled: false, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-01-01T00:00:00.000Z", - distinct_id: "user-123", - schema_version: 1, - }), - ); + const initial = { + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + distinct_id: "user-123", + schema_version: 1, + }; return Effect.gen(function* () { + yield* writeTelemetryConfig(dir, initial); yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); expect(out.stdoutText).toBe("Telemetry is enabled.\n"); - const config = readTelemetryConfig(dir); + const config = yield* readTelemetryConfig(dir); expect(config.enabled).toBe(true); expect(config.device_id).toBe("device-123"); expect(config.distinct_id).toBe("user-123"); expect(config.schema_version).toBe(1); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); it.live("disable preserves prior identity fields and prints Go-style disabled output", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { out, layer } = setup(dir); - writeFileSync( - telemetryPath(dir), - JSON.stringify({ - enabled: true, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-01-01T00:00:00.000Z", - distinct_id: "user-123", - schema_version: 1, - }), - ); + const initial = { + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + distinct_id: "user-123", + schema_version: 1, + }; return Effect.gen(function* () { + yield* writeTelemetryConfig(dir, initial); yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); expect(out.stdoutText).toBe("Telemetry is disabled.\n"); - const config = readTelemetryConfig(dir); + const config = yield* readTelemetryConfig(dir); expect(config.enabled).toBe(false); expect(config.device_id).toBe("device-123"); expect(config.distinct_id).toBe("user-123"); expect(config.schema_version).toBe(1); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); it.live("status recovers a malformed legacy telemetry.json instead of failing", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { out, layer } = setup(dir); - writeFileSync(telemetryPath(dir), "{not valid json}"); - return Effect.gen(function* () { + yield* writeTelemetryFile(dir, "{not valid json}"); yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "status"]); expect(out.stdoutText).toBe("Telemetry is enabled.\n"); - const config = readTelemetryConfig(dir); + const config = yield* readTelemetryConfig(dir); expect(config.enabled).toBe(true); expect(config.schema_version).toBe(1); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` is gated on @@ -193,113 +191,92 @@ describe("legacy telemetry integration", () => { // run the same commands through the REAL, consent-gated `legacyAnalyticsLayer` // (not this mock) to prove the production wiring doesn't crash end-to-end. it.live("disable no longer force-suppresses cli_command_executed", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { analytics, layer } = setup(dir); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); expect(analytics.captured.map((event) => event.event)).toContain(EventCommandExecuted); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); it.live("enable no longer force-suppresses cli_command_executed", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { analytics, layer } = setup(dir); return Effect.gen(function* () { yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); expect(analytics.captured.map((event) => event.event)).toContain(EventCommandExecuted); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }); it.live("disable runs cleanly through the real consent-gated analytics layer", () => { - const dir = makeTempDir(); - writeFileSync( - telemetryPath(dir), - JSON.stringify({ - enabled: true, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-01-01T00:00:00.000Z", - schema_version: 1, - }), - ); + const dir = tempRoot.current; + const initial = { + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + schema_version: 1, + }; const { out, layer } = setupWithRealAnalytics(dir); return Effect.gen(function* () { + yield* writeTelemetryConfig(dir, initial); yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "disable"]); expect(out.stdoutText).toBe("Telemetry is disabled.\n"); - expect(readTelemetryConfig(dir).enabled).toBe(false); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + expect((yield* readTelemetryConfig(dir)).enabled).toBe(false); + }).pipe(Effect.provide(layer)); }); it.live("enable runs cleanly through the real consent-gated analytics layer", () => { - const dir = makeTempDir(); - writeFileSync( - telemetryPath(dir), - JSON.stringify({ - enabled: false, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-01-01T00:00:00.000Z", - schema_version: 1, - }), - ); + const dir = tempRoot.current; + const initial = { + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-01-01T00:00:00.000Z", + schema_version: 1, + }; const { out, layer } = setupWithRealAnalytics(dir); return Effect.gen(function* () { + yield* writeTelemetryConfig(dir, initial); yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })(["telemetry", "enable"]); expect(out.stdoutText).toBe("Telemetry is enabled.\n"); - expect(readTelemetryConfig(dir).enabled).toBe(true); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + expect((yield* readTelemetryConfig(dir)).enabled).toBe(true); + }).pipe(Effect.provide(layer)); }); it.live( "status treats malformed typed fields as a corrupted file and regenerates identity", () => { - const dir = makeTempDir(); + const dir = tempRoot.current; const { out, layer } = setup(dir); - writeFileSync( - telemetryPath(dir), - JSON.stringify({ - enabled: false, - device_id: "device-123", - session_id: "session-123", - session_last_active: "not-a-time", - distinct_id: "user-123", - schema_version: 1, - }), - ); + const initial = { + enabled: false, + device_id: "device-123", + session_id: "session-123", + session_last_active: "not-a-time", + distinct_id: "user-123", + schema_version: 1, + }; return Effect.gen(function* () { + yield* writeTelemetryConfig(dir, initial); yield* Command.runWith(legacyTestRoot(), { version: "0.0.0-test" })([ "telemetry", "status", ]); expect(out.stdoutText).toBe("Telemetry is enabled.\n"); - const config = readTelemetryConfig(dir); + const config = yield* readTelemetryConfig(dir); expect(config.enabled).toBe(true); expect(config.device_id).not.toBe("device-123"); expect(config.session_id).not.toBe("session-123"); expect(config.distinct_id).toBeUndefined(); expect(config.schema_version).toBe(1); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ) as Effect.Effect<void>; + }).pipe(Effect.provide(layer)); }, ); }); diff --git a/apps/cli/src/legacy/commands/test/new/new.e2e.test.ts b/apps/cli/src/legacy/commands/test/new/new.e2e.test.ts index 5c62472ef6..be150d3166 100644 --- a/apps/cli/src/legacy/commands/test/new/new.e2e.test.ts +++ b/apps/cli/src/legacy/commands/test/new/new.e2e.test.ts @@ -1,7 +1,6 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; import { runSupabase } from "../../../../../tests/helpers/cli.ts"; @@ -16,29 +15,51 @@ const E2E_TIMEOUT_MS = 30_000; describe("supabase test new (legacy)", () => { let projectDir: string; - beforeAll(() => { - projectDir = mkdtempSync(join(tmpdir(), "supabase-test-new-e2e-")); - mkdirSync(join(projectDir, "supabase"), { recursive: true }); - writeFileSync(join(projectDir, "supabase", "config.toml"), 'project_id = "test-new-e2e"\n'); - }); + beforeAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + projectDir = yield* fs.makeTempDirectory({ prefix: "supabase-test-new-e2e-" }); + const supabaseDir = path.join(projectDir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString( + path.join(supabaseDir, "config.toml"), + 'project_id = "test-new-e2e"\n', + ); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); - afterAll(() => { - rmSync(projectDir, { recursive: true, force: true }); - }); + afterAll(() => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(projectDir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); test( "scaffolds supabase/tests/<name>_test.sql and prints the created path", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["test", "new", "pet"], { - entrypoint: "legacy", - cwd: projectDir, - }); - expect(exitCode).toBe(0); - expect(stdout).toContain("Created new pgtap test at"); - const target = join(projectDir, "supabase", "tests", "pet_test.sql"); - expect(existsSync(target)).toBe(true); - expect(readFileSync(target, "utf8")).toContain("SELECT plan(1);"); - }, + () => + Effect.runPromise( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const { exitCode, stdout } = yield* Effect.tryPromise(() => + runSupabase(["test", "new", "pet"], { + entrypoint: "legacy", + cwd: projectDir, + }), + ); + expect(exitCode).toBe(0); + expect(stdout).toContain("Created new pgtap test at"); + const target = path.join(projectDir, "supabase", "tests", "pet_test.sql"); + expect(yield* fs.exists(target)).toBe(true); + expect(yield* fs.readFileString(target)).toContain("SELECT plan(1);"); + }).pipe(Effect.provide(BunServices.layer)), + ), ); }); diff --git a/apps/cli/src/legacy/commands/test/new/new.handler.ts b/apps/cli/src/legacy/commands/test/new/new.handler.ts index 5a8d2d7c51..8f8931957a 100644 --- a/apps/cli/src/legacy/commands/test/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/test/new/new.handler.ts @@ -30,9 +30,10 @@ export const legacyTestNew = Effect.fn("legacy.test.new")(function* (flags: Lega const exists = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); if (exists) { - return yield* Effect.fail( - new LegacyTestNewFileExistsError({ path: relPath, message: `${relPath} already exists.` }), - ); + return yield* new LegacyTestNewFileExistsError({ + path: relPath, + message: `${relPath} already exists.`, + }); } // `utils.WriteFile` pins the dir to 0755 and the test file to 0644 diff --git a/apps/cli/src/legacy/commands/test/new/new.integration.test.ts b/apps/cli/src/legacy/commands/test/new/new.integration.test.ts index 7ffdeb0c7b..44558969ab 100644 --- a/apps/cli/src/legacy/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/legacy/commands/test/new/new.integration.test.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Layer, Option, Path } from "effect"; import { badArgument } from "effect/PlatformError"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -72,10 +69,12 @@ describe("legacy test new integration", () => { it.live("creates a pgtap test file and prints the created path", () => { const { layer, out, workdir } = setup(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* legacyTestNew(flags("pet")); - const target = join(workdir, "supabase", "tests", "pet_test.sql"); - expect(existsSync(target)).toBe(true); - expect(readFileSync(target, "utf8")).toBe(LEGACY_PGTAP_TEMPLATE); + const target = path.join(workdir, "supabase", "tests", "pet_test.sql"); + expect(yield* fs.exists(target)).toBe(true); + expect(yield* fs.readFileString(target)).toBe(LEGACY_PGTAP_TEMPLATE); expect(out.stdoutText).toContain("Created new pgtap test at "); expect(out.stdoutText).toContain("supabase/tests/pet_test.sql"); }).pipe(Effect.provide(layer)); @@ -85,26 +84,34 @@ describe("legacy test new integration", () => { const { layer, workdir } = setup(); const prevUmask = process.umask(0); return Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; yield* legacyTestNew(flags("modepin")); - const target = join(workdir, "supabase", "tests", "modepin_test.sql"); - expect(statSync(target).mode & 0o777).toBe(0o644); + const target = path.join(workdir, "supabase", "tests", "modepin_test.sql"); + expect((yield* fs.stat(target)).mode & 0o777).toBe(0o644); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); }); it.live("defaults the template to pgtap when --template is omitted", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; yield* legacyTestNew(flags("nodbtemplate")); - const target = join(workdir, "supabase", "tests", "nodbtemplate_test.sql"); - expect(readFileSync(target, "utf8")).toBe(LEGACY_PGTAP_TEMPLATE); + const target = path.join(workdir, "supabase", "tests", "nodbtemplate_test.sql"); + expect(yield* fs.readFileString(target)).toBe(LEGACY_PGTAP_TEMPLATE); }).pipe(Effect.provide(layer)); }); it.live("honors an explicit --template pgtap", () => { const { layer, workdir } = setup(); return Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; yield* legacyTestNew(flags("explicit", "pgtap")); - expect(existsSync(join(workdir, "supabase", "tests", "explicit_test.sql"))).toBe(true); + expect(yield* fs.exists(path.join(workdir, "supabase", "tests", "explicit_test.sql"))).toBe( + true, + ); }).pipe(Effect.provide(layer)); }); @@ -132,13 +139,16 @@ describe("legacy test new integration", () => { it.live("fails with LegacyTestNewFileExistsError when the file already exists", () => { const { layer, workdir } = setup(); - mkdirSync(join(workdir, "supabase", "tests"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "tests", "dupe_test.sql"), "-- existing\n"); return Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const testsDir = path.join(workdir, "supabase", "tests"); + yield* fs.makeDirectory(testsDir, { recursive: true }); + yield* fs.writeFileString(path.join(testsDir, "dupe_test.sql"), "-- existing\n"); const exit = yield* Effect.exit(legacyTestNew(flags("dupe"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyTestNewFileExistsError"); expect(json).toContain("supabase/tests/dupe_test.sql already exists."); } @@ -151,7 +161,7 @@ describe("legacy test new integration", () => { const exit = yield* Effect.exit(legacyTestNew(flags("nowrite"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyTestNewWriteError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyTestNewWriteError"); } }).pipe(Effect.provide(layer)); }); @@ -162,7 +172,7 @@ describe("legacy test new integration", () => { const exit = yield* Effect.exit(legacyTestNew(flags("nomkdir"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyTestNewWriteError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyTestNewWriteError"); } }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts b/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts index fefcb29577..69d5c4dc7e 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- legacy e2e exercises the subprocess and temporary filesystem boundary directly. import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/apps/cli/src/legacy/commands/unlink/unlink.handler.ts b/apps/cli/src/legacy/commands/unlink/unlink.handler.ts index 132a14b5d5..8eda28634a 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.handler.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Path, Result } from "effect"; +import { Effect, FileSystem, Path, Predicate, Result } from "effect"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyCredentialDeleteError } from "../../auth/legacy-errors.ts"; @@ -25,9 +25,7 @@ export const legacyUnlink = Effect.fn("legacy.unlink")(function* () { // read failure surfaces verbatim (unlink.go:16-19). const exists = yield* fs.exists(paths.projectRef).pipe(Effect.orElseSucceed(() => false)); if (!exists) { - return yield* Effect.fail( - new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); + return yield* new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); } // Go reads the raw bytes without trimming — `link` writes the ref with no // trailing newline, so the value round-trips exactly (used for both the @@ -67,14 +65,12 @@ export const legacyUnlink = Effect.fn("legacy.unlink")(function* () { // collected message, not just the first. Keep the leading failure's tag // (temp removal precedes the credential delete, matching that order). if (rest.length === 0) { - return yield* Effect.fail(first); + return yield* first; } const message = collected.map((e) => e.message).join("\n"); - return yield* Effect.fail( - first._tag === "LegacyUnlinkTempRemovalError" - ? new LegacyUnlinkTempRemovalError({ message }) - : new LegacyCredentialDeleteError({ message }), - ); + return yield* Predicate.isTagged(first, "LegacyUnlinkTempRemovalError") + ? new LegacyUnlinkTempRemovalError({ message }) + : new LegacyCredentialDeleteError({ message }); } // 3. PostRun: `Finished supabase unlink.` to stdout (text), structured success diff --git a/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts b/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts index 5841e78098..ab24d02800 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.integration.test.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Layer, Option, Path } from "effect"; import { badArgument } from "effect/PlatformError"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -54,8 +51,21 @@ const failingRemoveFsLayer = Layer.effect( ).pipe(Layer.provide(BunServices.layer)); function seedProjectRef(workdir: string, ref: string) { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", ".temp", "project-ref"), ref); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const target = path.join(workdir, "supabase", ".temp", "project-ref"); + yield* fs.makeDirectory(path.dirname(target), { recursive: true }); + yield* fs.writeFileString(target, ref); + }).pipe(Effect.provide(BunServices.layer)); +} + +function tempDirectoryExists(workdir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.exists(path.join(workdir, "supabase", ".temp")); + }).pipe(Effect.provide(BunServices.layer)); } function setup(opts: SetupOpts = {}) { @@ -81,10 +91,10 @@ function setup(opts: SetupOpts = {}) { describe("legacy unlink integration", () => { it.live("unlinks: removes the temp dir, deletes the keyring entry, prints Finished", () => { const { layer, out, credentials, workdir } = setup(); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); yield* legacyUnlink(); - expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); + expect(yield* tempDirectoryExists(workdir)).toBe(false); expect(credentials.deletedRefs).toEqual([LEGACY_VALID_REF]); expect(out.stdoutText).toContain("Finished supabase unlink."); }).pipe(Effect.provide(layer)); @@ -92,8 +102,8 @@ describe("legacy unlink integration", () => { it.live("writes 'Unlinking project: <ref>' to stderr", () => { const { layer, out, workdir } = setup(); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); yield* legacyUnlink(); expect(out.stderrText).toContain(`Unlinking project: ${LEGACY_VALID_REF}`); }).pipe(Effect.provide(layer)); @@ -103,8 +113,8 @@ describe("legacy unlink integration", () => { // The tracked credentials mock returns `true`; a real not-found returns // `false` without erroring — either way unlink succeeds. const { layer, out, workdir } = setup(); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); yield* legacyUnlink(); expect(out.stdoutText).toContain("Finished supabase unlink."); }).pipe(Effect.provide(layer)); @@ -116,7 +126,7 @@ describe("legacy unlink integration", () => { const exit = yield* Effect.exit(legacyUnlink()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyProjectNotLinkedError"); expect(json).toContain("Cannot find project ref"); } @@ -125,26 +135,26 @@ describe("legacy unlink integration", () => { it.live("fails when the keyring delete errors (permission denied)", () => { const { layer, workdir } = setup({ deleteFails: true }); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); const exit = yield* Effect.exit(legacyUnlink()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyCredentialDeleteError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyCredentialDeleteError"); } // The temp dir is still removed before the credential delete is attempted. - expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); + expect(yield* tempDirectoryExists(workdir)).toBe(false); }).pipe(Effect.provide(layer)); }); it.live("fails with LegacyUnlinkTempRemovalError when the temp dir cannot be removed", () => { const { layer, workdir } = setup({ removeFails: true }); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); const exit = yield* Effect.exit(legacyUnlink()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyUnlinkTempRemovalError"); expect(json).toContain("failed to remove temp directory"); } @@ -153,12 +163,12 @@ describe("legacy unlink integration", () => { it.live("surfaces both messages when temp removal and keyring delete both fail", () => { const { layer, workdir } = setup({ removeFails: true, deleteFails: true }); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); const exit = yield* Effect.exit(legacyUnlink()); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); // errors.Join parity — both failure messages are surfaced, not just the first. expect(json).toContain("failed to remove temp directory"); expect(json).toContain("failed to delete project credential"); @@ -168,8 +178,8 @@ describe("legacy unlink integration", () => { it.live("flushes telemetry via ensuring", () => { const { layer, telemetry, workdir } = setup(); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); yield* legacyUnlink(); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -177,8 +187,8 @@ describe("legacy unlink integration", () => { it.live("json output: emits a structured success and suppresses the Finished line", () => { const { layer, out, workdir } = setup({ format: "json" }); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); yield* legacyUnlink(); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); @@ -188,8 +198,8 @@ describe("legacy unlink integration", () => { it.live("stream-json output: emits a structured success", () => { const { layer, out, workdir } = setup({ format: "stream-json" }); - seedProjectRef(workdir, LEGACY_VALID_REF); return Effect.gen(function* () { + yield* seedProjectRef(workdir, LEGACY_VALID_REF); yield* legacyUnlink(); const success = out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ project_ref: LEGACY_VALID_REF }); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 36aa49d600..c23f64af02 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -56,11 +56,9 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain // wrappers, after ref resolution. Cobra checks the flag was *changed*, // not non-empty, so `--desired-subdomain ""` passes and reaches the API. if (Option.isNone(flags.desiredSubdomain)) { - return yield* Effect.fail( - new LegacyDesiredSubdomainRequiredError({ - message: `required flag(s) "desired-subdomain" not set`, - }), - ); + return yield* new LegacyDesiredSubdomainRequiredError({ + message: `required flag(s) "desired-subdomain" not set`, + }); } const desiredSubdomain = flags.desiredSubdomain.value; const activating = @@ -86,16 +84,14 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain statusCode: mapped.status, response: legacyGateResponse(cause), }); - return yield* Effect.fail( - new LegacyVanitySubdomainsActivateUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - }), - ); + return yield* new LegacyVanitySubdomainsActivateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }), ), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index e9405c98cc..663bcdbd5b 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -57,11 +57,9 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( // Cobra checks the flag was *changed*, not non-empty, so // `--desired-subdomain ""` passes and reaches the API. if (Option.isNone(flags.desiredSubdomain)) { - return yield* Effect.fail( - new LegacyDesiredSubdomainRequiredError({ - message: `required flag(s) "desired-subdomain" not set`, - }), - ); + return yield* new LegacyDesiredSubdomainRequiredError({ + message: `required flag(s) "desired-subdomain" not set`, + }); } const desiredSubdomain = flags.desiredSubdomain.value; const checking = @@ -90,16 +88,14 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( response: legacyGateResponse(cause), trackAnalytics: false, }); - return yield* Effect.fail( - new LegacyVanitySubdomainsCheckUnexpectedStatusError({ - status: mapped.status, - body: mapped.body, - message: mapped.message, - upgradeSuggested, - }), - ); + return yield* new LegacyVanitySubdomainsCheckUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); } - return yield* Effect.fail(mapped); + return yield* mapped; }), ), ); diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts index 0b4293bd49..1ea24a9526 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Formatter, Layer } from "effect"; import { CliOutput, Command } from "effect/unstable/cli"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; @@ -109,7 +109,7 @@ describe("legacy vanity-subdomains experimental gate (Go PersistentPreRunE parit ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyExperimentalRequiredError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyExperimentalRequiredError"); } expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -124,7 +124,7 @@ describe("legacy vanity-subdomains experimental gate (Go PersistentPreRunE parit ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = JSON.stringify(exit.cause); + const causeText = Formatter.formatJson(exit.cause); expect(causeText).not.toContain("LegacyExperimentalRequiredError"); expect(causeText).toContain("LegacyPlatformAuthRequiredError"); } diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index b79a395326..06c94500c4 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -4,7 +4,7 @@ import type { V1GetVanitySubdomainConfigOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Formatter, Option } from "effect"; import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -266,7 +266,7 @@ describe("legacy vanity-subdomains get", () => { const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyVanitySubdomainsGetUnexpectedStatusError"); expect(errorJson).toContain("unexpected vanity subdomain status 503"); } @@ -283,7 +283,7 @@ describe("legacy vanity-subdomains get", () => { const exit = yield* Effect.exit(legacyVanitySubdomainsGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyVanitySubdomainsGetNetworkError"); expect(errorJson).toContain("failed to get vanity subdomain"); } @@ -416,7 +416,7 @@ describe("legacy vanity-subdomains check-availability", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyVanitySubdomainsCheckNetworkError"); expect(errorJson).toContain("failed to check vanity subdomain"); } @@ -594,7 +594,7 @@ describe("legacy vanity-subdomains activate", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyVanitySubdomainsActivateNetworkError"); expect(errorJson).toContain("failed activate vanity subdomain"); } @@ -688,7 +688,7 @@ describe("legacy vanity-subdomains delete", () => { const exit = yield* Effect.exit(legacyVanitySubdomainsDelete({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyVanitySubdomainsDeleteUnexpectedStatusError"); expect(errorJson).toContain("unexpected delete vanity subdomain status 503"); } @@ -705,7 +705,7 @@ describe("legacy vanity-subdomains delete", () => { const exit = yield* Effect.exit(legacyVanitySubdomainsDelete({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyVanitySubdomainsDeleteNetworkError"); expect(errorJson).toContain("failed to delete vanity subdomain"); } diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts index 8ec73a60f2..51f8b50991 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { Config, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { lastExplicitLongFlagValue } from "../../shared/cli/cobra-flag-groups.ts"; import { CLI_VERSION } from "../../shared/cli/version.ts"; @@ -38,6 +38,7 @@ function resolveProfile( flagValue: string, explicitFlagValue: string | undefined, envValue: string | undefined, + configuredHome: string | undefined, fs: FileSystem.FileSystem, path: Path.Path, homeDir: string, @@ -56,7 +57,7 @@ function resolveProfile( token = envValue; } else { // Lowest precedence: the persisted `~/.supabase/profile` file. - const filePath = legacyProfileFilePath(path, homeDir); + const filePath = legacyProfileFilePath(path, homeDir, configuredHome); const content = yield* fs.readFileString(filePath).pipe( Effect.tap(() => debugLogger.debug(`Loading profile from file: ${filePath}`)), Effect.map(Option.some), @@ -131,7 +132,11 @@ export const legacyCliConfigLayer = Layer.unwrap( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; - const env = process.env; + const profileEnv = yield* Config.option(Config.string("SUPABASE_PROFILE")); + const supabaseHomeEnv = yield* Config.option(Config.string("SUPABASE_HOME")); + const accessTokenEnv = yield* Config.option(Config.string("SUPABASE_ACCESS_TOKEN")); + const projectIdEnv = yield* Config.option(Config.string("SUPABASE_PROJECT_ID")); + const workdirEnv = yield* Config.option(Config.string("SUPABASE_WORKDIR")); // `serviceOption`: tests without argv default to "not explicit". The // empty command path scans all of argv up to `--`, like pflag. @@ -150,20 +155,21 @@ export const legacyCliConfigLayer = Layer.unwrap( } = yield* resolveProfile( profileFlag, explicitProfileFlag, - env["SUPABASE_PROFILE"], + Option.getOrUndefined(profileEnv), + Option.getOrUndefined(supabaseHomeEnv), fs, path, runtimeInfo.homeDir, debugLogger, ); - const rawAccessToken = env["SUPABASE_ACCESS_TOKEN"]; + const rawAccessToken = Option.getOrUndefined(accessTokenEnv); const accessToken = rawAccessToken === undefined || rawAccessToken.length === 0 ? Option.none<Redacted.Redacted<string>>() : Option.some(Redacted.make(rawAccessToken, { label: "SUPABASE_ACCESS_TOKEN" })); - const rawProjectId = env["SUPABASE_PROJECT_ID"]; + const rawProjectId = Option.getOrUndefined(projectIdEnv); const projectId = rawProjectId === undefined || rawProjectId.length === 0 ? Option.none<string>() @@ -171,7 +177,7 @@ export const legacyCliConfigLayer = Layer.unwrap( const workdir = yield* resolveWorkdir( workdirFlag, - env["SUPABASE_WORKDIR"], + Option.getOrUndefined(workdirEnv), runtimeInfo.cwd, (filePath) => fs.exists(filePath).pipe(Effect.orElseSucceed(() => false)), path, diff --git a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts index 15982bd582..17ab4735ba 100644 --- a/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-cli-config.layer.unit.test.ts @@ -1,11 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, Layer, Option, Redacted } from "effect"; -import { afterEach, beforeEach, vi } from "vitest"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Cause, Effect, FileSystem, Exit, Layer, Option, Path, Redacted } from "effect"; +import { vi } from "vitest"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { @@ -14,6 +10,7 @@ import { LegacyWorkdirFlag, } from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { legacyDebugLoggerLayer } from "../shared/legacy-debug-logger.layer.ts"; import { LegacyProfileLoadError } from "../shared/legacy-profile-load.ts"; import { legacyCliConfigLayer } from "./legacy-cli-config.layer.ts"; @@ -31,7 +28,7 @@ function makeLayer(opts: { }) { const profileFlag = opts.profileFlag ?? "supabase"; const workdirFlag = opts.workdirFlag ?? Option.none<string>(); - return legacyCliConfigLayer.pipe( + const configLayer = legacyCliConfigLayer.pipe( Layer.provide(legacyDebugLoggerLayer), Layer.provide(Layer.succeed(LegacyDebugFlag, opts.debug ?? false)), Layer.provide(Layer.succeed(LegacyProfileFlag, profileFlag)), @@ -43,19 +40,45 @@ function makeLayer(opts: { Layer.provide( mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", - homeDir: opts.home ?? join(tempRoot, "home"), + homeDir: opts.home ?? path.join(tempRoot.current, "home"), }), ), Layer.provide(BunServices.layer), Layer.provide(processEnvLayer(opts.env ?? {})), ); + return Layer.mergeAll(configLayer, BunServices.layer); } // Profile load failures surface as layer-build failures (Go: PersistentPreRunE). function configExit(opts: Parameters<typeof makeLayer>[0]) { - return Effect.gen(function* () { - return yield* LegacyCliConfig; - }).pipe(Effect.provide(makeLayer(opts)), Effect.exit); + return Effect.exit(LegacyCliConfig.pipe(Effect.provide(makeLayer(opts)))); +} + +function profileFixtureLayer(fixtures: ReadonlyArray<readonly [string, string]>) { + return Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + for (const [filePath, contents] of fixtures) { + yield* fs.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); + } + }).pipe(Effect.provide(BunServices.layer)), + ); +} + +function makeLayerWithFixtures( + opts: Parameters<typeof makeLayer>[0], + fixtures: ReadonlyArray<readonly [string, string]>, +) { + return makeLayer(opts).pipe(Layer.provide(profileFixtureLayer(fixtures))); +} + +function configExitWithFixtures( + opts: Parameters<typeof makeLayer>[0], + fixtures: ReadonlyArray<readonly [string, string]>, +) { + return Effect.exit(LegacyCliConfig.pipe(Effect.provide(makeLayerWithFixtures(opts, fixtures)))); } function expectProfileLoadFailure(exit: Exit.Exit<unknown, unknown>, ...fragments: string[]) { @@ -71,15 +94,8 @@ function expectProfileLoadFailure(exit: Exit.Exit<unknown, unknown>, ...fragment } } -let tempRoot: string; - -beforeEach(() => { - tempRoot = mkdtempSync(join(tmpdir(), "supabase-legacy-cli-config-")); -}); - -afterEach(() => { - rmSync(tempRoot, { recursive: true, force: true }); -}); +const tempRoot = useLegacyTempWorkdir("supabase-legacy-cli-config-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); describe("legacyCliConfigLayer", () => { it.effect("defaults to supabase profile and api.supabase.com when no flags or env", () => @@ -90,7 +106,7 @@ describe("legacyCliConfigLayer", () => { expect(config.projectHost).toBe("supabase.co"); expect(config.poolerHost).toBe("supabase.com"); expect(config.dashboardUrl).toBe("https://supabase.com/dashboard"); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current }))), ); it.effect("uses SUPABASE_PROFILE env when the flag is left at default", () => @@ -101,7 +117,9 @@ describe("legacyCliConfigLayer", () => { expect(config.projectHost).toBe("supabase.red"); expect(config.poolerHost).toBe("supabase.green"); }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot })), + Effect.provide( + makeLayer({ env: { SUPABASE_PROFILE: "supabase-staging" }, cwd: tempRoot.current }), + ), ), ); @@ -109,7 +127,7 @@ describe("legacyCliConfigLayer", () => { Effect.gen(function* () { const config = yield* LegacyCliConfig; expect(config.apiUrl).toBe("http://localhost:8080"); - }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ profileFlag: "supabase-local", cwd: tempRoot.current }))), ); it.effect("resolves the snap profile API URL and project host", () => @@ -117,39 +135,53 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.apiUrl).toBe("https://cloudapi.snap.com"); expect(config.projectHost).toBe("snapcloud.dev"); - }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ profileFlag: "snap", cwd: tempRoot.current }))), ); it.effect("reads the persisted ~/.supabase/profile file when no flag/env is set", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "supabase-staging\n"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "supabase-staging\n"); const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase-staging"); - }).pipe(Effect.provide(makeLayer({ home, cwd: tempRoot }))); + }).pipe( + Effect.provide( + makeLayerWithFixtures({ home, cwd: tempRoot.current }, [ + [path.join(home, ".supabase", "profile"), "supabase-staging\n"], + ]), + ), + ); }); it.effect("reads the persisted profile file from SUPABASE_HOME when configured", () => { - const home = join(tempRoot, "home"); - const supabaseHome = join(tempRoot, "custom-supabase-home"); - mkdirSync(supabaseHome, { recursive: true }); - writeFileSync(join(supabaseHome, "profile"), "supabase-staging\n"); + const home = path.join(tempRoot.current, "home"); + const supabaseHome = path.join(tempRoot.current, "custom-supabase-home"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseHome, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseHome, "profile"), "supabase-staging\n"); const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase-staging"); }).pipe( - Effect.provide(makeLayer({ home, cwd: tempRoot, env: { SUPABASE_HOME: supabaseHome } })), + Effect.provide( + makeLayerWithFixtures( + { home, cwd: tempRoot.current, env: { SUPABASE_HOME: supabaseHome } }, + [[path.join(supabaseHome, "profile"), "supabase-staging\n"]], + ), + ), ); }); it.effect("debug logs the persisted profile file source", () => { - const home = join(tempRoot, "home"); - const profilePath = join(home, ".supabase", "profile"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(profilePath, "supabase-staging\n"); + const home = path.join(tempRoot.current, "home"); + const profilePath = path.join(home, ".supabase", "profile"); const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(profilePath, "supabase-staging\n"); const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase-staging"); expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain( @@ -157,21 +189,29 @@ describe("legacyCliConfigLayer", () => { ); }).pipe( Effect.ensuring(Effect.sync(() => stderr.mockRestore())), - Effect.provide(makeLayer({ home, cwd: tempRoot, debug: true })), + Effect.provide( + makeLayerWithFixtures({ home, cwd: tempRoot.current, debug: true }, [ + [profilePath, "supabase-staging\n"], + ]), + ), ); }); it.effect("flag and env take precedence over the persisted profile file", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "supabase-staging"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "supabase-staging"); const config = yield* LegacyCliConfig; // SUPABASE_PROFILE wins over the file. expect(config.profile).toBe("supabase-local"); }).pipe( Effect.provide( - makeLayer({ home, cwd: tempRoot, env: { SUPABASE_PROFILE: "supabase-local" } }), + makeLayerWithFixtures( + { home, cwd: tempRoot.current, env: { SUPABASE_PROFILE: "supabase-local" } }, + [[path.join(home, ".supabase", "profile"), "supabase-staging"]], + ), ), ); }); @@ -184,7 +224,7 @@ describe("legacyCliConfigLayer", () => { Effect.gen(function* () { const exit = yield* configExit({ env: { SUPABASE_PROFILE: "rogue-profile" }, - cwd: tempRoot, + cwd: tempRoot.current, }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }), @@ -192,7 +232,7 @@ describe("legacyCliConfigLayer", () => { it.effect("fails when --profile names a non-existent profile instead of falling back", () => Effect.gen(function* () { - const exit = yield* configExit({ profileFlag: "resms", cwd: tempRoot }); + const exit = yield* configExit({ profileFlag: "resms", cwd: tempRoot.current }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }), ); @@ -202,7 +242,7 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase-staging"); expect(config.apiUrl).toBe("https://api.supabase.green"); - }).pipe(Effect.provide(makeLayer({ profileFlag: "SUPABASE-STAGING", cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ profileFlag: "SUPABASE-STAGING", cwd: tempRoot.current }))), ); // pflag `Changed`: an explicitly passed flag counts even at its default value. @@ -218,7 +258,7 @@ describe("legacyCliConfigLayer", () => { makeLayer({ argv: ["link", "--profile", "supabase"], env: { SUPABASE_PROFILE: "rogue-profile" }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -234,7 +274,7 @@ describe("legacyCliConfigLayer", () => { makeLayer({ argv: ["link", "--profile", "rogue-profile", "--profile", "supabase"], profileFlag: "rogue-profile", - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -245,37 +285,44 @@ describe("legacyCliConfigLayer", () => { const exit = yield* configExit({ argv: ["link", "--profile", "supabase", "--profile", "resms"], profileFlag: "supabase", - cwd: tempRoot, + cwd: tempRoot.current, }); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }), ); it.effect("explicit --profile supabase shadows an unloadable persisted profile file", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "resms\n"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(home, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(home, ".supabase", "profile"), "resms\n"); const config = yield* LegacyCliConfig; expect(config.profile).toBe("supabase"); }).pipe( - Effect.provide(makeLayer({ argv: ["login", "--profile=supabase"], home, cwd: tempRoot })), + Effect.provide( + makeLayerWithFixtures( + { argv: ["login", "--profile=supabase"], home, cwd: tempRoot.current }, + [[path.join(home, ".supabase", "profile"), "resms\n"]], + ), + ), ); }); it.effect("loads api_url, name, pooler_host, and dashboard_url from a YAML profile file", () => { - const profilePath = join(tempRoot, "profile.yaml"); - writeFileSync( - profilePath, - [ - "name: cli-e2e", - 'api_url: "http://127.0.0.1:9999"', - "project_host: localhost", - "pooler_host: staging.example.com", - 'dashboard_url: "http://127.0.0.1:9999"', - ].join("\n"), - ); + const profilePath = path.join(tempRoot.current, "profile.yaml"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + profilePath, + [ + "name: cli-e2e", + 'api_url: "http://127.0.0.1:9999"', + "project_host: localhost", + "pooler_host: staging.example.com", + 'dashboard_url: "http://127.0.0.1:9999"', + ].join("\n"), + ); const config = yield* LegacyCliConfig; expect(config.profile).toBe("cli-e2e"); expect(config.apiUrl).toBe("http://127.0.0.1:9999"); @@ -284,34 +331,55 @@ describe("legacyCliConfigLayer", () => { // Go reads `dashboard_url` from the profile (used by the connect-failure hint); // the cli-e2e harness points it at the replay server for parity. expect(config.dashboardUrl).toBe("http://127.0.0.1:9999"); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }).pipe( + Effect.provide( + makeLayerWithFixtures({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot.current }, [ + [ + profilePath, + 'name: cli-e2e\napi_url: "http://127.0.0.1:9999"\nproject_host: localhost\npooler_host: staging.example.com\ndashboard_url: "http://127.0.0.1:9999"', + ], + ]), + ), + ); }); it.effect("keeps pooler_host empty when a YAML profile omits it — Go omitempty", () => { - const profilePath = join(tempRoot, "no-pooler.yaml"); - writeFileSync( - profilePath, - [ - "name: cli-e2e", - 'api_url: "http://127.0.0.1:9999"', - 'dashboard_url: "http://127.0.0.1:9999"', - "project_host: localhost", - ].join("\n"), - ); + const profilePath = path.join(tempRoot.current, "no-pooler.yaml"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + profilePath, + [ + "name: cli-e2e", + 'api_url: "http://127.0.0.1:9999"', + 'dashboard_url: "http://127.0.0.1:9999"', + "project_host: localhost", + ].join("\n"), + ); const config = yield* LegacyCliConfig; expect(config.projectHost).toBe("localhost"); // An absent pooler_host disables the MITM domain assertion rather // than falling back to supabase.com. expect(config.poolerHost).toBe(""); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); + }).pipe( + Effect.provide( + makeLayerWithFixtures({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot.current }, [ + [ + profilePath, + 'name: cli-e2e\napi_url: "http://127.0.0.1:9999"\ndashboard_url: "http://127.0.0.1:9999"\nproject_host: localhost', + ], + ]), + ), + ); }); it.effect("fails when a YAML profile omits required keys — Go validator parity", () => { - const profilePath = join(tempRoot, "no-host.yaml"); - writeFileSync(profilePath, ["name: cli-e2e", 'api_url: "http://127.0.0.1:9999"'].join("\n")); + const profilePath = path.join(tempRoot.current, "no-host.yaml"); return Effect.gen(function* () { - const exit = yield* configExit({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }); + const exit = yield* configExitWithFixtures( + { env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot.current }, + [[profilePath, 'name: cli-e2e\napi_url: "http://127.0.0.1:9999"']], + ); expectProfileLoadFailure( exit, "invalid profile:", @@ -323,8 +391,11 @@ describe("legacyCliConfigLayer", () => { it.effect("fails when SUPABASE_PROFILE points to a non-existent file — Go parity", () => Effect.gen(function* () { - const missingPath = join(tempRoot, "missing.yaml"); - const exit = yield* configExit({ env: { SUPABASE_PROFILE: missingPath }, cwd: tempRoot }); + const missingPath = path.join(tempRoot.current, "missing.yaml"); + const exit = yield* configExit({ + env: { SUPABASE_PROFILE: missingPath }, + cwd: tempRoot.current, + }); expectProfileLoadFailure( exit, `failed to read profile: open ${missingPath}: no such file or directory`, @@ -333,21 +404,23 @@ describe("legacyCliConfigLayer", () => { ); it.effect("fails when SUPABASE_PROFILE points to malformed YAML — Go parity", () => { - const profilePath = join(tempRoot, "broken.yaml"); - writeFileSync(profilePath, "::: not yaml :::\n[unbalanced"); + const profilePath = path.join(tempRoot.current, "broken.yaml"); return Effect.gen(function* () { - const exit = yield* configExit({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }); + const exit = yield* configExitWithFixtures( + { env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot.current }, + [[profilePath, "::: not yaml :::\n[unbalanced"]], + ); expectProfileLoadFailure(exit, "failed to read profile: While parsing config:"); }); }); // Files written by older lenient versions still exist and must fail like Go. it.effect("fails when the persisted profile file names an unloadable profile", () => { - const home = join(tempRoot, "home"); - mkdirSync(join(home, ".supabase"), { recursive: true }); - writeFileSync(join(home, ".supabase", "profile"), "resms\n"); + const home = path.join(tempRoot.current, "home"); return Effect.gen(function* () { - const exit = yield* configExit({ home, cwd: tempRoot }); + const exit = yield* configExitWithFixtures({ home, cwd: tempRoot.current }, [ + [path.join(home, ".supabase", "profile"), "resms\n"], + ]); expectProfileLoadFailure(exit, "failed to read profile: Unsupported Config Type"); }); }); @@ -358,7 +431,7 @@ describe("legacyCliConfigLayer", () => { expect(config.apiUrl).toBe("https://api.supabase.com"); }).pipe( Effect.provide( - makeLayer({ env: { SUPABASE_API_URL: "https://nope.example.com" }, cwd: tempRoot }), + makeLayer({ env: { SUPABASE_API_URL: "https://nope.example.com" }, cwd: tempRoot.current }), ), ), ); @@ -371,7 +444,9 @@ describe("legacyCliConfigLayer", () => { expect(Redacted.value(config.accessToken.value)).toBe("sbp_test"); } }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_ACCESS_TOKEN: "sbp_test" }, cwd: tempRoot })), + Effect.provide( + makeLayer({ env: { SUPABASE_ACCESS_TOKEN: "sbp_test" }, cwd: tempRoot.current }), + ), ), ); @@ -381,7 +456,7 @@ describe("legacyCliConfigLayer", () => { expect(Option.getOrUndefined(config.projectId)).toBe("myrefabcdefghijklmno"); }).pipe( Effect.provide( - makeLayer({ env: { SUPABASE_PROJECT_ID: "myrefabcdefghijklmno" }, cwd: tempRoot }), + makeLayer({ env: { SUPABASE_PROJECT_ID: "myrefabcdefghijklmno" }, cwd: tempRoot.current }), ), ), ); @@ -395,7 +470,7 @@ describe("legacyCliConfigLayer", () => { makeLayer({ workdirFlag: Option.some("/flag/workdir"), env: { SUPABASE_WORKDIR: "/env/workdir" }, - cwd: tempRoot, + cwd: tempRoot.current, }), ), ), @@ -406,7 +481,9 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.workdir).toBe("/env/workdir"); }).pipe( - Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot })), + Effect.provide( + makeLayer({ env: { SUPABASE_WORKDIR: "/env/workdir" }, cwd: tempRoot.current }), + ), ), ); @@ -419,22 +496,22 @@ describe("legacyCliConfigLayer", () => { it.effect("resolves a relative --workdir flag against the real cwd", () => Effect.gen(function* () { const config = yield* LegacyCliConfig; - expect(config.workdir).toBe(tempRoot); - }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot }))), + expect(config.workdir).toBe(tempRoot.current); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("."), cwd: tempRoot.current }))), ); it.effect("resolves a relative --workdir flag with a subdirectory against the real cwd", () => Effect.gen(function* () { const config = yield* LegacyCliConfig; - expect(config.workdir).toBe(join(tempRoot, "sub")); - }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot }))), + expect(config.workdir).toBe(path.join(tempRoot.current, "sub")); + }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some("sub"), cwd: tempRoot.current }))), ); it.effect("resolves a relative SUPABASE_WORKDIR env value against the real cwd", () => Effect.gen(function* () { const config = yield* LegacyCliConfig; - expect(config.workdir).toBe(tempRoot); - }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot }))), + expect(config.workdir).toBe(tempRoot.current); + }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_WORKDIR: "." }, cwd: tempRoot.current }))), ); it.effect("keeps an absolute --workdir flag unchanged", () => @@ -442,28 +519,40 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; expect(config.workdir).toBe("/flag/workdir"); }).pipe( - Effect.provide(makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot })), + Effect.provide( + makeLayer({ workdirFlag: Option.some("/flag/workdir"), cwd: tempRoot.current }), + ), ), ); it.effect("walks up from CWD looking for supabase/config.toml", () => { - const projectRoot = join(tempRoot, "project"); - const nested = join(projectRoot, "deep", "child"); - mkdirSync(join(projectRoot, "supabase"), { recursive: true }); - mkdirSync(nested, { recursive: true }); - writeFileSync(join(projectRoot, "supabase", "config.toml"), 'project_id = "x"\n'); + const projectRoot = path.join(tempRoot.current, "project"); + const nested = path.join(projectRoot, "deep", "child"); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(nested, { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "x"\n', + ); const config = yield* LegacyCliConfig; expect(config.workdir).toBe(projectRoot); - }).pipe(Effect.provide(makeLayer({ cwd: nested }))); + }).pipe( + Effect.provide( + makeLayerWithFixtures({ cwd: nested }, [ + [path.join(projectRoot, "supabase", "config.toml"), 'project_id = "x"\n'], + ]), + ), + ); }); it.effect("falls back to CWD when no supabase/config.toml found", () => Effect.gen(function* () { const config = yield* LegacyCliConfig; - expect(config.workdir).toBe(tempRoot); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + expect(config.workdir).toBe(tempRoot.current); + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current }))), ); it.effect("populates userAgent from CLI_VERSION", () => @@ -471,6 +560,6 @@ describe("legacyCliConfigLayer", () => { const config = yield* LegacyCliConfig; // The sentinel `0.0.0-dev` value applies when SUPABASE_CLI_VERSION is unset (tests). expect(config.userAgent).toMatch(/^SupabaseCLI\//); - }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), + }).pipe(Effect.provide(makeLayer({ cwd: tempRoot.current }))), ); }); diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts index 2415628392..5059eb4ccb 100644 --- a/apps/cli/src/legacy/config/legacy-profile-file.ts +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -21,10 +21,11 @@ import { * directly, so `env` defaults to it. */ export function legacySupabaseHome( + path: Path.Path, + configuredHome: string | undefined, homeDir: string, - env: Readonly<Record<string, string | undefined>> = process.env, ): string { - return resolveSupabaseHome(env, homeDir); + return resolveSupabaseHome(path, configuredHome, homeDir); } /** Raised when persisting the profile name fails — fails `login` outright, @@ -40,9 +41,9 @@ export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveE export function legacyProfileFilePath( path: Path.Path, homeDir: string, - env?: Readonly<Record<string, string | undefined>>, + configuredHome?: string, ): string { - return path.join(legacySupabaseHome(homeDir, env), "profile"); + return path.join(legacySupabaseHome(path, configuredHome, homeDir), "profile"); } /** Writes the profile name to `<SUPABASE_HOME or ~/.supabase>/profile`. Fatal on failure. */ @@ -51,15 +52,15 @@ export const saveLegacyProfileName = ( path: Path.Path, homeDir: string, name: string, + configuredHome?: string, ): Effect.Effect<void, LegacyProfileSaveError> => Effect.gen(function* () { - const filePath = legacyProfileFilePath(path, homeDir); + const filePath = legacyProfileFilePath(path, homeDir, configuredHome); yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, name); }).pipe( - Effect.catch((error) => - Effect.fail( + Effect.mapError( + (error) => new LegacyProfileSaveError({ message: `failed to save profile: ${error.message}` }), - ), ), ); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts index 0659d874e6..fc29598432 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.ts @@ -95,9 +95,7 @@ export const legacyProjectRefLayer = Layer.effect( const chosen = yield* promptForProjectRef("Select a project:"); return yield* assertValid(chosen); } - return yield* Effect.fail( - new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); + return yield* new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); }), resolveForLink: (flagValue) => Effect.gen(function* () { @@ -112,11 +110,9 @@ export const legacyProjectRefLayer = Layer.effect( const chosen = yield* promptForProjectRef("Select a project:"); return yield* assertValid(chosen); } - return yield* Effect.fail( - new LegacyProjectRefRequiredError({ - message: `required flag(s) "project-ref" not set`, - }), - ); + return yield* new LegacyProjectRefRequiredError({ + message: `required flag(s) "project-ref" not set`, + }); }), resolveOptional: (flagValue) => Effect.gen(function* () { @@ -146,9 +142,7 @@ export const legacyProjectRefLayer = Layer.effect( if (Option.isSome(fileValue)) { return yield* assertValid(fileValue.value); } - return yield* Effect.fail( - new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), - ); + return yield* new LegacyProjectNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }); }), promptProjectRef: promptForProjectRef, }); diff --git a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts index 6a0d39814c..949bdcdbd6 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.layer.unit.test.ts @@ -1,12 +1,7 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import type { ApiClient } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Layer, Option } from "effect"; -import { afterEach, beforeEach } from "vitest"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Effect, Exit, FileSystem, Formatter, Layer, Option, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyPlatformApi } from "../auth/legacy-platform-api.service.ts"; @@ -14,6 +9,7 @@ import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { LegacyCliConfig } from "./legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "./legacy-project-ref.service.ts"; import { legacyProjectRefLayer } from "./legacy-project-ref.layer.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; const VALID_REF = "abcdefghijklmnopqrst"; const ANOTHER_REF = "qrstuvwxyzabcdefghij"; @@ -60,12 +56,13 @@ function makeLayer(opts: { region: string; }>; promptSelectResponses?: ReadonlyArray<string>; + refFile?: string; }) { const out = mockOutput({ format: opts.format ?? "text", promptSelectResponses: opts.promptSelectResponses, }); - const layer = legacyProjectRefLayer.pipe( + const baseLayer = legacyProjectRefLayer.pipe( Layer.provide(mockCliConfig(opts)), Layer.provide(mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false })), Layer.provide(out.layer), @@ -76,29 +73,33 @@ function makeLayer(opts: { ), Layer.provide(BunServices.layer), ); + const refFile = opts.refFile; + const refFileLayer = + refFile === undefined + ? Layer.empty + : Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const refPath = path.join(opts.workdir, "supabase", ".temp", "project-ref"); + yield* fs.makeDirectory(path.dirname(refPath), { recursive: true }); + yield* fs.writeFileString(refPath, refFile); + }).pipe(Effect.provide(BunServices.layer)), + ); + const layer = Layer.mergeAll(baseLayer.pipe(Layer.provide(refFileLayer)), BunServices.layer); return { layer, out }; } -let tempRoot: string; - -beforeEach(() => { - tempRoot = mkdtempSync(join(tmpdir(), "supabase-legacy-project-ref-")); -}); - -afterEach(() => { - rmSync(tempRoot, { recursive: true, force: true }); -}); - -function writeRefFile(workdir: string, content: string) { - const tempDir = join(workdir, "supabase", ".temp"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "project-ref"), content); -} +const tempRoot = useLegacyTempWorkdir("supabase-legacy-project-ref-"); +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); describe("legacyProjectRefLayer", () => { it.effect("prefers --project-ref flag over env and file", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ + workdir: tempRoot.current, + projectId: ANOTHER_REF, + refFile: ANOTHER_REF, + }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const ref = yield* resolve(Option.some(VALID_REF)); @@ -107,8 +108,11 @@ describe("legacyProjectRefLayer", () => { }); it.effect("uses SUPABASE_PROJECT_ID when flag is unset", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ + workdir: tempRoot.current, + projectId: VALID_REF, + refFile: ANOTHER_REF, + }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const ref = yield* resolve(Option.none()); @@ -117,8 +121,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("reads <workdir>/supabase/.temp/project-ref when env and flag are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current, refFile: VALID_REF }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const ref = yield* resolve(Option.none()); @@ -127,8 +130,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("trims whitespace from the temp/project-ref file content", () => { - writeRefFile(tempRoot, ` ${VALID_REF}\n\n`); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current, refFile: ` ${VALID_REF}\n\n` }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const ref = yield* resolve(Option.none()); @@ -142,7 +144,7 @@ describe("legacyProjectRefLayer", () => { { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [ANOTHER_REF], @@ -168,9 +170,9 @@ describe("legacyProjectRefLayer", () => { const projects = [ { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, ]; - const refPath = join(tempRoot, "supabase", ".temp", "project-ref"); + const refPath = path.join(tempRoot.current, "supabase", ".temp", "project-ref"); const { layer } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [VALID_REF], @@ -179,18 +181,19 @@ describe("legacyProjectRefLayer", () => { const { resolve } = yield* LegacyProjectRefResolver; yield* resolve(Option.none()); // The resolver must not write the file — only `supabase link` does. - expect(existsSync(refPath)).toBe(false); + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(refPath)).toBe(false); }).pipe(Effect.provide(layer)); }); it.effect("fails with LegacyProjectNotLinkedError on non-TTY with no source", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyProjectNotLinkedError"); expect(errorJson).toContain("supabase link"); } @@ -198,13 +201,13 @@ describe("legacyProjectRefLayer", () => { }); it.effect("fails with LegacyInvalidProjectRefError when the resolved ref is malformed", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: "not-a-valid-ref" }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyInvalidProjectRefError"); expect(errorJson).toContain("Invalid project ref format"); } @@ -212,7 +215,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("rejects invalid ref from --project-ref flag", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.some("BADREF"))); @@ -221,8 +224,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("rejects invalid ref from temp/project-ref file", () => { - writeRefFile(tempRoot, "BADREF"); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current, refFile: "BADREF" }); return Effect.gen(function* () { const { resolve } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(resolve(Option.none())); @@ -232,8 +234,11 @@ describe("legacyProjectRefLayer", () => { describe("resolveOptional", () => { it.effect("prefers the flag value", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ + workdir: tempRoot.current, + projectId: ANOTHER_REF, + refFile: ANOTHER_REF, + }); return Effect.gen(function* () { const { resolveOptional } = yield* LegacyProjectRefResolver; const ref = yield* resolveOptional(Option.some(VALID_REF)); @@ -242,7 +247,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("falls back to projectId then the ref file", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: VALID_REF }); return Effect.gen(function* () { const { resolveOptional } = yield* LegacyProjectRefResolver; const ref = yield* resolveOptional(Option.none()); @@ -251,8 +256,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("reads the ref file when flag and projectId are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current, refFile: VALID_REF }); return Effect.gen(function* () { const { resolveOptional } = yield* LegacyProjectRefResolver; const ref = yield* resolveOptional(Option.none()); @@ -261,7 +265,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("returns None and never fails when nothing resolves", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveOptional } = yield* LegacyProjectRefResolver; const ref = yield* resolveOptional(Option.none()); @@ -272,8 +276,11 @@ describe("legacyProjectRefLayer", () => { describe("loadProjectRef (Go flags.LoadProjectRef — non-prompting)", () => { it.effect("prefers flag, then projectId, then the ref file", () => { - writeRefFile(tempRoot, ANOTHER_REF); - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ + workdir: tempRoot.current, + projectId: ANOTHER_REF, + refFile: ANOTHER_REF, + }); return Effect.gen(function* () { const { loadProjectRef } = yield* LegacyProjectRefResolver; expect(yield* loadProjectRef(Option.some(VALID_REF))).toBe(VALID_REF); @@ -281,8 +288,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("reads the ref file when flag and projectId are unset", () => { - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current, refFile: VALID_REF }); return Effect.gen(function* () { const { loadProjectRef } = yield* LegacyProjectRefResolver; expect(yield* loadProjectRef(Option.none())).toBe(VALID_REF); @@ -295,7 +301,7 @@ describe("legacyProjectRefLayer", () => { // lint`/`db advisors --linked` use loadProjectRef, which fails with // LegacyProjectNotLinkedError instead of prompting. const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects: [ { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, @@ -307,7 +313,7 @@ describe("legacyProjectRefLayer", () => { const exit = yield* Effect.exit(loadProjectRef(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyProjectNotLinkedError"); expect(errorJson).toContain("supabase link"); } @@ -316,13 +322,13 @@ describe("legacyProjectRefLayer", () => { }); it.effect("validates the resolved ref format", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: "not-a-valid-ref" }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: "not-a-valid-ref" }); return Effect.gen(function* () { const { loadProjectRef } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(loadProjectRef(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -330,7 +336,7 @@ describe("legacyProjectRefLayer", () => { describe("resolveForLink", () => { it.effect("prefers the --project-ref flag", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: ANOTHER_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: ANOTHER_REF }); return Effect.gen(function* () { const { resolveForLink } = yield* LegacyProjectRefResolver; const ref = yield* resolveForLink(Option.some(VALID_REF)); @@ -339,7 +345,7 @@ describe("legacyProjectRefLayer", () => { }); it.effect("uses SUPABASE_PROJECT_ID when the flag is unset", () => { - const { layer } = makeLayer({ workdir: tempRoot, projectId: VALID_REF }); + const { layer } = makeLayer({ workdir: tempRoot.current, projectId: VALID_REF }); return Effect.gen(function* () { const { resolveForLink } = yield* LegacyProjectRefResolver; const ref = yield* resolveForLink(Option.none()); @@ -350,14 +356,13 @@ describe("legacyProjectRefLayer", () => { it.effect("skips the ref file (Go MemMapFs) and fails off-TTY with no flag/projectId", () => { // A ref file is present, but link must ignore it and fail like cobra's // required-flag check would. - writeRefFile(tempRoot, VALID_REF); - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current, refFile: VALID_REF }); return Effect.gen(function* () { const { resolveForLink } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(resolveForLink(Option.none())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); + const errorJson = Formatter.formatJson(exit.cause); expect(errorJson).toContain("LegacyProjectRefRequiredError"); expect(errorJson).toContain(`required flag(s) \\"project-ref\\" not set`); } @@ -369,7 +374,7 @@ describe("legacyProjectRefLayer", () => { { id: VALID_REF, name: "alpha", organization_slug: "acme", region: "us-east-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [VALID_REF], @@ -383,13 +388,13 @@ describe("legacyProjectRefLayer", () => { }); it.effect("rejects an invalid --project-ref flag", () => { - const { layer } = makeLayer({ workdir: tempRoot }); + const { layer } = makeLayer({ workdir: tempRoot.current }); return Effect.gen(function* () { const { resolveForLink } = yield* LegacyProjectRefResolver; const exit = yield* Effect.exit(resolveForLink(Option.some("BADREF"))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyInvalidProjectRefError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyInvalidProjectRefError"); } }).pipe(Effect.provide(layer)); }); @@ -402,7 +407,7 @@ describe("legacyProjectRefLayer", () => { { id: ANOTHER_REF, name: "beta", organization_slug: "acme", region: "eu-west-1" }, ]; const { layer, out } = makeLayer({ - workdir: tempRoot, + workdir: tempRoot.current, stdinIsTty: true, projects, promptSelectResponses: [ANOTHER_REF], diff --git a/apps/cli/src/legacy/docs/generate-docs-spec.script.unit.test.ts b/apps/cli/src/legacy/docs/generate-docs-spec.script.unit.test.ts index 97969f0979..1dd847c507 100644 --- a/apps/cli/src/legacy/docs/generate-docs-spec.script.unit.test.ts +++ b/apps/cli/src/legacy/docs/generate-docs-spec.script.unit.test.ts @@ -1,7 +1,9 @@ -import path from "node:path"; +import { BunPath } from "@effect/platform-bun"; +import { Effect, Path } from "effect"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); const cliRoot = path.resolve(import.meta.dirname, "../../.."); /** diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.content.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.content.ts index adecd95a18..215d60895a 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.content.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.content.ts @@ -1,7 +1,12 @@ -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; +import { Data, Effect, FileSystem, Path } from "effect"; +import type * as PlatformError from "effect/PlatformError"; import { parse } from "yaml"; import type { LegacyDocsExample } from "./legacy-docs-spec.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Loads the docs-spec content inputs from `apps/cli/docs/`: the description @@ -16,22 +21,57 @@ export interface LegacyDocsContent { readonly examples: Readonly<Record<string, ReadonlyArray<LegacyDocsExample>>>; } -export function legacyReadDocsContent(docsDir: string): LegacyDocsContent { - const overlays = new Map<string, string>(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) walk(entryPath); - else if (entry.name.endsWith(".md")) { - const key = path.relative(docsDir, entryPath).split(path.sep).join("/"); - overlays.set(key, readFileSync(entryPath, "utf8")); - } - } - }; - walk(path.join(docsDir, "supabase")); +export class LegacyDocsContentParseError extends Data.TaggedError("LegacyDocsContentParseError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} - const examplesPath = path.join(docsDir, "templates/examples.yaml"); - return { overlays, examples: legacyParseExamples(parse(readFileSync(examplesPath, "utf8"))) }; +export function legacyReadDocsContent( + docsDir: string, +): Effect.Effect< + LegacyDocsContent, + PlatformError.PlatformError | LegacyDocsContentParseError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const overlays = new Map<string, string>(); + const walk = (dir: string): Effect.Effect<void, PlatformError.PlatformError> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(dir); + for (const name of entries) { + const entryPath = path.join(dir, name); + const info = yield* fs.stat(entryPath); + if (info.type === "Directory") { + yield* walk(entryPath); + } else if (name.endsWith(".md")) { + const key = path.relative(docsDir, entryPath).split(path.sep).join("/"); + overlays.set(key, yield* fs.readFileString(entryPath)); + } + } + }); + yield* walk(path.join(docsDir, "supabase")); + const examplesPath = path.join(docsDir, "templates/examples.yaml"); + const examplesText = yield* fs.readFileString(examplesPath); + const examples = yield* Effect.try({ + try: () => parse(examplesText), + catch: (cause) => + new LegacyDocsContentParseError({ + message: String(cause), + }), + }); + return yield* Effect.try({ + try: () => ({ overlays, examples: legacyParseExamples(examples) }), + catch: (cause) => + new LegacyDocsContentParseError({ + message: String(cause), + }), + }); + }); } /** diff --git a/apps/cli/src/legacy/docs/legacy-docs-spec.unit.test.ts b/apps/cli/src/legacy/docs/legacy-docs-spec.unit.test.ts index a6d9b30131..06ef7c149e 100644 --- a/apps/cli/src/legacy/docs/legacy-docs-spec.unit.test.ts +++ b/apps/cli/src/legacy/docs/legacy-docs-spec.unit.test.ts @@ -1,13 +1,13 @@ -import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { Command, Flag } from "effect/unstable/cli"; -import { describe, expect, it } from "vitest"; +import { Effect, FileSystem, Path } from "effect"; +import type * as PlatformError from "effect/PlatformError"; +import { describe, expect, it } from "@effect/vitest"; import { LegacyExperimentalFlag } from "../../shared/legacy/global-flags.ts"; import { legacyRoot } from "../cli/root.ts"; -import { legacyReadDocsContent } from "./legacy-docs-spec.content.ts"; +import { legacyReadDocsContent, type LegacyDocsContent } from "./legacy-docs-spec.content.ts"; import { legacyBuildDocsSpec, legacyDocsOverlayPath, @@ -18,29 +18,33 @@ import type { LegacyDocsCommand } from "./legacy-docs-spec.ts"; import { LEGACY_DOCS_EXCLUDED, LEGACY_DOCS_EXPERIMENTAL } from "./legacy-docs-spec.tables.ts"; interface LegacyBuiltSpecFixture { - readonly content: ReturnType<typeof legacyReadDocsContent>; + readonly content: LegacyDocsContent; readonly spec: ReturnType<typeof legacyBuildDocsSpec>; readonly byId: ReadonlyMap<string, LegacyDocsCommand>; } -let legacyBuiltSpecCache: LegacyBuiltSpecFixture | undefined; +const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + +const legacyBuiltSpecCache: LegacyBuiltSpecFixture = await Effect.runPromise( + legacyReadDocsContent(path.resolve(import.meta.dirname, "../../../docs")).pipe( + Effect.provide(BunServices.layer), + Effect.map((content) => { + const spec = legacyBuildDocsSpec({ + root: legacyRoot, + version: "1.2.3", + overlays: content.overlays, + examples: content.examples, + }); + return { + content, + spec, + byId: new Map(spec.commands.map((command) => [command.id, command])), + }; + }), + ), +); -/** Lazily built once, so runs filtered to the pure helpers skip the tree walk and disk reads. */ function legacyBuiltSpec(): LegacyBuiltSpecFixture { - if (legacyBuiltSpecCache === undefined) { - const content = legacyReadDocsContent(path.resolve(import.meta.dirname, "../../../docs")); - const spec = legacyBuildDocsSpec({ - root: legacyRoot, - version: "1.2.3", - overlays: content.overlays, - examples: content.examples, - }); - legacyBuiltSpecCache = { - content, - spec, - byId: new Map(spec.commands.map((command) => [command.id, command])), - }; - } return legacyBuiltSpecCache; } @@ -369,57 +373,77 @@ describe("build guards fail loudly", () => { }); describe("legacyReadDocsContent rejects malformed examples.yaml", () => { - function withTempDocs(examplesYaml: string): () => void { - const dir = mkdtempSync(path.join(tmpdir(), "docs-spec-test-")); - mkdirSync(path.join(dir, "supabase"), { recursive: true }); - mkdirSync(path.join(dir, "templates"), { recursive: true }); - writeFileSync(path.join(dir, "supabase", "link.md"), "## supabase-link\n\nBody.\n"); - writeFileSync(path.join(dir, "templates", "examples.yaml"), examplesYaml); - return () => { - try { - legacyReadDocsContent(dir); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }; + function withTempDocs(examplesYaml: string): Promise<void> { + return Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "docs-spec-test-" }); + const overlayPath = pathService.join(dir, "supabase", "link.md"); + const examplesPath = pathService.join(dir, "templates", "examples.yaml"); + yield* fs.makeDirectory(pathService.dirname(overlayPath), { recursive: true }); + yield* fs.makeDirectory(pathService.dirname(examplesPath), { recursive: true }); + yield* fs.writeFileString(overlayPath, "## supabase-link\n\nBody.\n"); + yield* fs.writeFileString(examplesPath, examplesYaml); + yield* legacyReadDocsContent(dir).pipe( + Effect.ensuring(fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); } it("rejects a non-mapping document", () => { - expect(withTempDocs("- just\n- a list\n")).toThrow(/must be a mapping of doc ids/); + return expect(withTempDocs("- just\n- a list\n")).rejects.toThrow( + /must be a mapping of doc ids/, + ); }); it("rejects a nested-array entry (mis-indented list)", () => { - expect(withTempDocs("supabase-link:\n - - id: oops\n")).toThrow(/must be a mapping/); + return expect(withTempDocs("supabase-link:\n - - id: oops\n")).rejects.toThrow( + /must be a mapping/, + ); }); it("rejects unknown fields (typo'd keys)", () => { - expect(withTempDocs("supabase-link:\n - titel: typo\n code: x\n")).toThrow( + return expect(withTempDocs("supabase-link:\n - titel: typo\n code: x\n")).rejects.toThrow( /unknown field "titel"/, ); }); it("rejects non-string field values", () => { - expect(withTempDocs("supabase-link:\n - id: 5\n")).toThrow(/id must be a string/); + return expect(withTempDocs("supabase-link:\n - id: 5\n")).rejects.toThrow( + /id must be a string/, + ); }); }); describe("LEGACY_DOCS_EXPERIMENTAL mirrors the runtime gate", () => { - it("matches the legacyRequireExperimental call sites exactly", () => { - const commandsDir = path.resolve(import.meta.dirname, "../commands"); - const gated = new Set<string>(); - const walk = (dir: string): void => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - const entryPath = path.join(dir, entry.name); - if (entry.isDirectory()) walk(entryPath); - else if (entry.name.endsWith(".command.ts")) { - if (readFileSync(entryPath, "utf8").includes("yield* legacyRequireExperimental")) { - const relative = path.relative(commandsDir, path.dirname(entryPath)); - gated.add(`supabase-${relative.split(path.sep).join("-")}`); + it.effect("matches the legacyRequireExperimental call sites exactly", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const commandsDir = pathService.resolve(import.meta.dirname, "../commands"); + const gated = new Set<string>(); + const walk = ( + dir: string, + ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + for (const entry of yield* fs.readDirectory(dir)) { + const entryPath = pathService.join(dir, entry); + const info = yield* fs.stat(entryPath); + if (info.type === "Directory") yield* walk(entryPath); + else if (entry.endsWith(".command.ts")) { + if ( + (yield* fs.readFileString(entryPath)).includes("yield* legacyRequireExperimental") + ) { + const relative = pathService.relative(commandsDir, pathService.dirname(entryPath)); + gated.add(`supabase-${relative.split(pathService.sep).join("-")}`); + } + } } - } - } - }; - walk(commandsDir); - expect([...gated].sort()).toEqual([...LEGACY_DOCS_EXPERIMENTAL].sort()); - }); + }); + yield* walk(commandsDir); + expect([...gated].sort()).toEqual([...LEGACY_DOCS_EXPERIMENTAL].sort()); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts index 828737b3d1..575eb9008d 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.ts @@ -324,15 +324,13 @@ export function legacyEnsureNetwork( ); if (exitCode !== 0 && !legacyIsNetworkAlreadyExistsError(stderr)) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyNetworkCreateError({ - message: - message.length > 0 - ? `failed to create docker network: ${message}` - : "failed to create docker network", - reason: legacyContainerCliReason(message), - }), - ); + return yield* new LegacyNetworkCreateError({ + message: + message.length > 0 + ? `failed to create docker network: ${message}` + : "failed to create docker network", + reason: legacyContainerCliReason(message), + }); } }), ); @@ -399,15 +397,11 @@ export function legacyEnsureVolume( ); if (exitCode !== 0 && !legacyIsVolumeAlreadyExistsError(stderr)) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyVolumeCreateError({ - message: - message.length > 0 - ? `failed to create volume: ${message}` - : "failed to create volume", - reason: legacyContainerCliReason(message), - }), - ); + return yield* new LegacyVolumeCreateError({ + message: + message.length > 0 ? `failed to create volume: ${message}` : "failed to create volume", + reason: legacyContainerCliReason(message), + }); } }), ); @@ -605,15 +599,13 @@ function legacyDockerCreateContainer( ); if (exitCode !== 0) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyContainerCreateError({ - message: - message.length > 0 - ? `failed to create docker container: ${message}` - : "failed to create docker container", - reason: legacyContainerCliReason(message), - }), - ); + return yield* new LegacyContainerCreateError({ + message: + message.length > 0 + ? `failed to create docker container: ${message}` + : "failed to create docker container", + reason: legacyContainerCliReason(message), + }); } return stdout.trim(); }), @@ -659,20 +651,16 @@ function legacyDockerStartContainer( }`; const hostPort = legacyParsePortBindError(trimmed); if (hostPort === undefined) { - return yield* Effect.fail( - new LegacyContainerStartError({ - message: base, - reason: legacyContainerCliReason(trimmed), - }), - ); + return yield* new LegacyContainerStartError({ + message: base, + reason: legacyContainerCliReason(trimmed), + }); } const serviceLabel = spec.networkAliases?.[0] ?? spec.containerName; - return yield* Effect.fail( - new LegacyContainerStartError({ - message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, - reason: "port_conflict", - }), - ); + return yield* new LegacyContainerStartError({ + message: `${base}${legacyPortConflictSuggestion(hostPort, serviceLabel)}`, + reason: "port_conflict", + }); } }), ); @@ -718,15 +706,13 @@ function legacyDockerCopyArchiveIntoContainer( ); if (exitCode !== 0) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyContainerCreateError({ - message: - message.length > 0 - ? `failed to create docker container: failed to copy secret file into container: ${message}` - : "failed to create docker container: failed to copy secret file into container", - reason: legacyContainerCliReason(message), - }), - ); + return yield* new LegacyContainerCreateError({ + message: + message.length > 0 + ? `failed to create docker container: failed to copy secret file into container: ${message}` + : "failed to create docker container: failed to copy secret file into container", + reason: legacyContainerCliReason(message), + }); } }), ); @@ -746,21 +732,18 @@ function legacyCopyStartSecretFilesIntoContainer( ): Effect.Effect<void, LegacyContainerCreateError> { if (secretFiles.length === 0) return Effect.void; - return Effect.tryPromise({ - try: () => - containerArchiveBytes( - Object.fromEntries( - secretFiles.map((secretFile) => [secretFile.containerPath, secretFile.content]), - ), - ), - catch: (cause) => - new LegacyContainerCreateError({ - message: `failed to create docker container: failed to prepare container secret files: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - reason: "internal", - }), - }).pipe( + return containerArchiveBytes( + Object.fromEntries( + secretFiles.map((secretFile) => [secretFile.containerPath, secretFile.content]), + ), + ).pipe( + Effect.mapError( + (cause) => + new LegacyContainerCreateError({ + message: `failed to create docker container: failed to prepare container secret files: ${cause.message}`, + reason: "internal", + }), + ), Effect.flatMap((archive) => legacyDockerCopyArchiveIntoContainer(spawner, archive, `${containerId}:/`), ), diff --git a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts index b678a48350..0f132a7f37 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/container-lifecycle.unit.test.ts @@ -1,11 +1,9 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, PlatformError, Sink, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { afterEach, beforeEach } from "vitest"; +import { beforeEach } from "vitest"; + +import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyContainerCreateError, @@ -25,13 +23,10 @@ import { import type { LegacyStartContainerSpec } from "./docker-create-args.ts"; let workdir: string; +const tempWorkdir = useLegacyTempWorkdir("supabase-legacy-start-container-lifecycle-"); beforeEach(() => { - workdir = mkdtempSync(join(tmpdir(), "supabase-legacy-start-container-lifecycle-")); -}); - -afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); + workdir = tempWorkdir.current; }); /** Matches the standing `mockSpawner` shape used across `legacy-docker-*.unit.test.ts` files, generalized to a per-call handler for multi-step orchestration (volume create -> container create -> container start). */ @@ -609,11 +604,7 @@ describe("legacyEnsureNetwork", () => { stderr: "Error response from daemon: network with name supabase_network_proj already exists\n", })); - return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", {}).pipe( - Effect.map(() => { - // Just needs to not fail — no return value to assert on. - }), - ); + return legacyEnsureNetwork(mock.spawner, "supabase_network_proj", {}).pipe(Effect.asVoid); }); it.live("fails with LegacyNetworkCreateError on any other failure", () => { @@ -678,11 +669,7 @@ describe("legacyEnsureVolume", () => { exitCode: 125, stderr: "Error: volume with name supabase_db_proj already exists: volume already exists\n", })); - return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( - Effect.map(() => { - // Just needs to not fail — no return value to assert on. - }), - ); + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe(Effect.asVoid); }); it.live("treats an already-exists rejection without the trailing sentinel as success", () => { @@ -690,11 +677,7 @@ describe("legacyEnsureVolume", () => { exitCode: 125, stderr: "volume with name supabase_db_proj already exists\n", })); - return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe( - Effect.map(() => { - // Just needs to not fail — no return value to assert on. - }), - ); + return legacyEnsureVolume(mock.spawner, "supabase_db_proj", {}).pipe(Effect.asVoid); }); it.live("fails with LegacyVolumeCreateError on any other failure", () => { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index 9378a2d360..f2c2078bad 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -121,6 +121,7 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import type { LocalServiceVersionOverrides } from "../../../shared/services/services.shared.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; +import { LegacyViperEnv } from "../../../shared/legacy/legacy-viper-env.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -130,11 +131,7 @@ import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connectio import type { LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; import { redactLegacyConnectionString } from "../legacy-db-config.parse.ts"; -import { - legacyApplyProjectEnv, - legacyCheckDbToml, - legacyResolveSeedSqlPath, -} from "../legacy-db-config.toml-read.ts"; +import { legacyCheckDbToml, legacyResolveSeedSqlPath } from "../legacy-db-config.toml-read.ts"; import { legacyParseBoolEnv } from "../legacy-diff-engine.ts"; import { LEGACY_CLI_PROJECT_LABEL, localDbContainerId } from "../legacy-docker-ids.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; @@ -702,12 +699,10 @@ const legacyRunStartMigrateJob = Effect.fnUntraced(function* ( ), ); if (result.exitCode !== 0) { - return yield* Effect.fail( - new LegacyDbSetupError({ - message: `error running container: exit ${result.exitCode}`, - reason: "database", - }), - ); + return yield* new LegacyDbSetupError({ + message: `error running container: exit ${result.exitCode}`, + reason: "database", + }); } }); @@ -984,40 +979,25 @@ export const legacyStartInitCurrentBranch = Effect.fnUntraced(function* ( workdir: string, ) { const currentBranchPath = path.join(workdir, "supabase", ".branches", "_current_branch"); - const exists = yield* fs.exists(currentBranchPath).pipe( - Effect.mapError( - (error) => - new LegacyDbSetupError({ - message: `failed init current branch: ${errMessage(error)}`, - reason: "filesystem", - }), - ), - ); + const mapFsError = (error: unknown) => + new LegacyDbSetupError({ + message: `failed init current branch: ${errMessage(error)}`, + reason: "filesystem", + }); + const exists = yield* fs.exists(currentBranchPath).pipe(Effect.mapError(mapFsError)); if (exists) return; - yield* fs.makeDirectory(path.dirname(currentBranchPath), { recursive: true }).pipe( - Effect.mapError( - (error) => - new LegacyDbSetupError({ - message: `failed init current branch: ${errMessage(error)}`, - reason: "filesystem", - }), - ), - ); + yield* fs + .makeDirectory(path.dirname(currentBranchPath), { recursive: true }) + .pipe(Effect.mapError(mapFsError)); // Go's `utils.WriteFile` writes through `afero.WriteFile(fsys, path, contents, 0644)` // (`internal/utils/misc.go:280-286`) — an explicit mode, not the platform default. Effect's // `writeFileString` falls back to Node's default file mode (`0666` before the umask) when no // `mode` is given, so under a permissive/group-writable umask (`000`/`002`) this file could be // created `0666`/`0664` instead of Go's `0644`, making project branch metadata writable by // additional local users. - yield* fs.writeFileString(currentBranchPath, "main", { mode: 0o644 }).pipe( - Effect.mapError( - (error) => - new LegacyDbSetupError({ - message: `failed init current branch: ${errMessage(error)}`, - reason: "filesystem", - }), - ), - ); + yield* fs + .writeFileString(currentBranchPath, "main", { mode: 0o644 }) + .pipe(Effect.mapError(mapFsError)); }); /** @@ -1141,6 +1121,7 @@ export const legacyStartSetupLocalDatabase = ( // at the CLI root runtime, same as `db push`'s own composition. | FileSystem.FileSystem | Path.Path + | LegacyViperEnv > => Effect.gen(function* () { const { session, fs, path, workdir } = input; @@ -1183,6 +1164,7 @@ export const legacyStartSetupLocalDatabase = ( // `--sql-paths` overrides on top of the loaded `[db.seed]` config — a no-op for // `db start`, which has neither flag. yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, { + projectEnv: toml.projectEnv, migrationsEnabled: toml.migrationsEnabled, seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), experimental: input.experimental, @@ -1215,7 +1197,7 @@ export const legacyStartSetupLocalDatabase = ( legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA"))); const pgDeltaImplementation = legacyResolvePgDeltaImplementation( legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], + toml.envLookup(LEGACY_PG_DELTA_NEXT_FLAG_NAME), toml.projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], ), ); @@ -1227,42 +1209,31 @@ export const legacyStartSetupLocalDatabase = ( projectEnv: toml.projectEnv, }; const hostDbUrl = new URL(input.dbUrl); - // Scope the `PGDELTA_NPM_REGISTRY`-from-project-`.env` apply to just this call: - // `legacyExportCatalogPgDelta` reads it off bare `process.env` - // (`legacyPgDeltaNpmRegistryOption`), same as `db push`/`db pull`/`db dump`/ - // `bootstrap`'s own calls into pg-delta — Go's `loadNestedEnv` already made it - // process-wide by this point (`config.go:788`), but this module otherwise threads - // every override through `projectEnvValues` explicitly rather than mutating - // `process.env`, so this one shared-code call needs the same opt-in helper those - // other commands use. `legacyApplyProjectEnv` registers a finalizer that reverts it. yield* Effect.scoped( - Effect.gen(function* () { - yield* legacyApplyProjectEnv(input.projectEnvValues ?? {}); - yield* legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { - // The catalog is a legacy-engine artifact with no in-process consumer. - enabled: cacheEnabled && pgDeltaImplementation === "legacy", - targetUrl: input.dbUrl, - conn: { - host: hostDbUrl.hostname, - port: Number(hostDbUrl.port), - user: "postgres", - database: "postgres", - }, - isLocal: true, - migrationsDir: path.join(workdir, "supabase", "migrations"), - }).pipe( - // Best-effort: Go's own `TryCacheMigrationsCatalog` failure only ever warns - // (`fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err)`, - // start.go:378) and never fails `legacyStartSetupLocalDatabase` — same shape - // `legacy-db-push-core.ts` already established for this exact call. - Effect.catch((error) => - output.raw( - `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, - "stderr", - ), + legacyTryCacheMigrationsCatalog(fs, path, pgDeltaCtx, { + // The catalog is a legacy-engine artifact with no in-process consumer. + enabled: cacheEnabled && pgDeltaImplementation === "legacy", + targetUrl: input.dbUrl, + conn: { + host: hostDbUrl.hostname, + port: Number(hostDbUrl.port), + user: "postgres", + database: "postgres", + }, + isLocal: true, + migrationsDir: path.join(workdir, "supabase", "migrations"), + }).pipe( + // Best-effort: Go's own `TryCacheMigrationsCatalog` failure only ever warns + // (`fmt.Fprintln(os.Stderr, "Warning: failed to cache migrations catalog:", err)`, + // start.go:378) and never fails `legacyStartSetupLocalDatabase` — same shape + // `legacy-db-push-core.ts` already established for this exact call. + Effect.catch((error) => + output.raw( + `Warning: failed to cache migrations catalog: ${redactLegacyConnectionString(error.message)}\n`, + "stderr", ), - ); - }), + ), + ), ); // `initCurrentBranch` (start.go:233-241) is NOT called here — see this @@ -1424,6 +1395,7 @@ export const legacyRunFreshDbSetup = <E>( | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path + | LegacyViperEnv > => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 0c3d2bad12..b15f9ff08c 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -1,14 +1,25 @@ -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import type { ProjectConfig } from "@supabase/config"; import { ProjectConfigSchema } from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, FileSystem, Layer, Path, Schema, Sink, Stream } from "effect"; +import { + Cause, + ConfigProvider, + Deferred, + Effect, + FileSystem, + Layer, + ManagedRuntime, + Path, + Schema, + Sink, + Stream, +} from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import type { PlatformError } from "effect/PlatformError"; import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../../tests/helpers/legacy-mocks.ts"; import { LegacyDbExecError } from "../legacy-db-connection.errors.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "../legacy-docker-run.service.ts"; @@ -19,6 +30,7 @@ import { type LegacyEdgeRuntimeRunOpts, } from "../legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "../legacy-pgdelta-ssl-probe.service.ts"; +import { makeLegacyViperEnvLayer } from "../../../shared/legacy/legacy-viper-env.ts"; import { LegacyDbSetupError, legacyResolveDbSetupPrelude, @@ -193,17 +205,76 @@ function mockPgDeltaSslProbeLayer() { }); } +const defaultConfig: ProjectConfig = decodeConfig({}); +const testPath = ManagedRuntime.make(BunServices.layer).runSync(Path.Path); +const tempRoot = useLegacyTempWorkdir("legacy-db-setup-"); +const fixtureWrites = new Map< + string, + Array<Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path>> +>(); + +const join = (...segments: ReadonlyArray<string>): string => testPath.join(...segments); function makeWorkdir(): string { - return mkdtempSync(join(tmpdir(), "legacy-db-setup-")); + return tempRoot.current; +} + +function queueFixture( + workdir: string, + effect: Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path>, +): void { + const existing = fixtureWrites.get(workdir) ?? []; + existing.push(effect); + fixtureWrites.set(workdir, existing); } function writeConfigToml(workdir: string, content: string): void { const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), content); + queueFixture( + workdir, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(join(supabaseDir, "config.toml"), content); + }), + ); } -const defaultConfig: ProjectConfig = decodeConfig({}); +function mkdirSync(path: string, options?: { readonly recursive?: boolean }): void { + const workdir = path.split("/supabase")[0] ?? path; + queueFixture( + workdir, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: options?.recursive ?? false }); + }), + ); +} + +function writeFileSync(path: string, content: string): void { + const workdir = path.split("/supabase")[0] ?? path; + queueFixture( + workdir, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = testPath.dirname(path); + yield* fs.makeDirectory(parent, { recursive: true }); + yield* fs.writeFileString(path, content); + }), + ); +} + +function rmSync( + _path: string, + _options?: { readonly recursive?: boolean; readonly force?: boolean }, +): void {} + +function flushFixtureWrites( + workdir: string, +): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> { + const writes = fixtureWrites.get(workdir) ?? []; + fixtureWrites.delete(workdir); + return Effect.forEach(writes, (write) => write, { discard: true }); +} function baseInput( workdir: string, @@ -249,6 +320,7 @@ const run = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + yield* flushFixtureWrites(input.workdir); return yield* legacyStartSetupLocalDatabase(mockAlwaysCachedSpawner(), { ...input, fs, @@ -258,6 +330,12 @@ const run = ( Effect.provide( Layer.mergeAll( BunServices.layer, + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ + env: input.projectEnvValues ?? {}, + preserveEmptyStrings: true, + }), + ), out.layer, docker.layer, mockRuntimeInfo({ platform: "darwin" }), @@ -545,7 +623,9 @@ describe("legacyStartSetupLocalDatabase", () => { Effect.flip, Effect.map((error) => { expect(error).toBeInstanceOf(LegacyDbSetupError); - expect((error as LegacyDbSetupError).message).toBe("error running container: exit 1"); + if (error instanceof LegacyDbSetupError) { + expect(error.message).toBe("error running container: exit 1"); + } rmSync(workdir, { recursive: true, force: true }); }), ); @@ -694,17 +774,22 @@ describe("legacyStartSetupLocalDatabase", () => { const docker = mockDockerRun(); const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker, edgeRuntime).pipe( - Effect.map(() => { - expect(edgeRuntime.calls).toHaveLength(1); - expect(out.stderrText).not.toContain("failed to cache migrations catalog"); - const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); - const catalogFiles = readdirSync(tempDir).filter((name) => - name.startsWith("catalog-local-migrations-"), - ); - expect(catalogFiles).toHaveLength(1); - expect(readFileSync(join(tempDir, catalogFiles[0]!), "utf8")).toBe('{"snapshot":"ok"}'); - rmSync(workdir, { recursive: true, force: true }); - }), + Effect.flatMap(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(edgeRuntime.calls).toHaveLength(1); + expect(out.stderrText).not.toContain("failed to cache migrations catalog"); + const tempDir = join(workdir, "supabase", ".temp", "pgdelta"); + const catalogFiles = (yield* fs.readDirectory(tempDir)).filter((name) => + name.startsWith("catalog-local-migrations-"), + ); + expect(catalogFiles).toHaveLength(1); + expect(yield* fs.readFileString(join(tempDir, catalogFiles[0]!))).toBe( + '{"snapshot":"ok"}', + ); + rmSync(workdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + ), ); }); @@ -716,8 +801,6 @@ describe("legacyStartSetupLocalDatabase", () => { // `supabase/.env` fallback below and resolve to the next implementation — // matching the engine-selector layer's own precedence rather than // `toml.envLookup`'s (which treats an empty shell value as unset). - const prev = process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - process.env["SUPABASE_USE_PG_DELTA_NEXT"] = ""; const workdir = makeWorkdir(); writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_USE_PG_DELTA_NEXT=false\n"); @@ -726,7 +809,10 @@ describe("legacyStartSetupLocalDatabase", () => { const docker = mockDockerRun(); const edgeRuntime = mockEdgeRuntime({ stdout: '{"snapshot":"ok"}' }); return run( - baseInput(workdir, session, { majorVersion: 14 }), + baseInput(workdir, session, { + majorVersion: 14, + projectEnvValues: { SUPABASE_USE_PG_DELTA_NEXT: "" }, + }), out, docker, edgeRuntime, @@ -735,12 +821,6 @@ describe("legacyStartSetupLocalDatabase", () => { expect(edgeRuntime.calls).toHaveLength(0); rmSync(workdir, { recursive: true, force: true }); }), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_USE_PG_DELTA_NEXT"]; - else process.env["SUPABASE_USE_PG_DELTA_NEXT"] = prev; - }), - ), ); }, ); @@ -773,19 +853,10 @@ describe("legacyStartSetupLocalDatabase", () => { ); it.effect( - "applies PGDELTA_NPM_REGISTRY from the project .env for the catalog export, then reverts it", + "passes PGDELTA_NPM_REGISTRY from the project .env to the catalog export explicitly", () => { - // Go's `Config.Load` already `os.Setenv`'d the project `.env` into the process - // (`loadNestedEnv`, config.go:788) long before `SetupLocalDatabase` runs, so a - // PGDELTA_NPM_REGISTRY set only in supabase/.env (not the shell) reaches - // `PgDeltaNpmRegistryOption` there. This module threads config overrides via - // `projectEnvValues` rather than mutating `process.env` globally, so the - // cache-warmup step must scope-apply it around just `legacyExportCatalogPgDelta`'s - // call (`legacyPgDeltaNpmRegistryOption` reads bare `process.env`) and revert - // afterwards — mirroring `db push`/`db pull`/`db dump`/`bootstrap`'s own use of - // `legacyApplyProjectEnv` for the same shared pg-delta code. - const previous = process.env["PGDELTA_NPM_REGISTRY"]; - delete process.env["PGDELTA_NPM_REGISTRY"]; + // The project value is threaded through `projectEnvValues` and encoded in the + // pg-delta invocation's explicit npm registry options; no ambient mutation is used. const workdir = makeWorkdir(); writeConfigToml(workdir, "[experimental.pgdelta]\nenabled = true\n"); mkdirSync(join(workdir, "supabase"), { recursive: true }); @@ -814,11 +885,6 @@ describe("legacyStartSetupLocalDatabase", () => { expect(edgeRuntime.calls[0]?.extraEnv?.["NPM_CONFIG_REGISTRY"]).toBe( "https://registry.example.com/supabase", ); - // Reverted: the scope closes once the cache-warmup call completes, so it - // never leaks into subsequent steps or other tests. - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBeUndefined(); - if (previous === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; - else process.env["PGDELTA_NPM_REGISTRY"] = previous; rmSync(workdir, { recursive: true, force: true }); }), ); @@ -890,7 +956,7 @@ describe("legacyResolveDbSetupPrelude", () => { { majorVersion: 15, realtimeEnabledForSetup: true, - jwks: Effect.fail(new Error("jwks discovery failed")), + jwks: Effect.fail(new Cause.UnknownError(undefined, String("jwks discovery failed"))), }, out, ).pipe( @@ -1063,6 +1129,7 @@ describe("legacyStartInitCurrentBranch", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + yield* flushFixtureWrites(workdir); yield* legacyStartInitCurrentBranch(fs, path, workdir); const content = yield* fs.readFileString( join(workdir, "supabase", ".branches", "_current_branch"), @@ -1084,6 +1151,7 @@ describe("legacyStartInitCurrentBranch", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + yield* flushFixtureWrites(workdir); yield* legacyStartInitCurrentBranch(fs, path, workdir); const content = yield* fs.readFileString(join(branchesDir, "_current_branch")); expect(content).toBe("feature-x"); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts index ab421387e2..fbde429d0a 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.ts @@ -103,28 +103,9 @@ export class LegacyHealthCheckTimeoutError extends Data.TaggedError( * `fetcher.NewServiceGateway(utils.Config.Api.ExternalUrl, * utils.Config.Auth.SecretKey.Value, ...)` (`status.go:213-218`). TLS/CA trust * for a local https gateway is the caller's responsibility when composing the - * `HttpClient.HttpClient` layer this module requires — same split as - * `legacy-storage-gateway.ts`/`legacyStorageGatewayFetch`. - * - * `start.command.ts` composes the `HttpClient.HttpClient` this module - * requires via `legacyHttpClientLayer` (itself `FetchHttpClient`-backed, and - * on its own CA-unaware; see that layer's own header) — the same layer - * `db reset`/`seed buckets` compose for the equivalent gateway calls. - * `start.handler.ts` layers a CA-trusting override on top of that: when - * `api.tls.enabled`, `apiExternalUrl` is `https://` against Kong's - * self-signed local cert (`KONG_LOCAL_CA_CERT`, or a validated - * `api.tls.cert_path` override), so before calling - * {@link legacyWaitForHealthyServices} it resolves that same CA via - * `legacy-storage-credentials.ts`'s `legacyResolveStorageCredentials` (the - * mechanism `seed buckets`/`storage`/`db reset` already use) and, when a - * local CA resolves, overrides `FetchHttpClient.Fetch` with - * `legacyStorageGatewayFetch` around the health-check call via - * `Effect.provideService`. That override only takes effect against a - * `FetchHttpClient`-backed `HttpClient.HttpClient` — exactly what - * `legacyHttpClientLayer` provides — so a stack started with - * `[api.tls] enabled = true` now gets a `legacyCheckHttpReady` probe that - * trusts the local Kong CA instead of exhausting `legacyWaitForHealthyServices`'s - * full 30s budget on a TLS verification failure. + * `HttpClient.HttpClient` layer this module requires. The start command also + * composes the explicit `LegacyLocalGatewayHttpClient` boundary, which selects + * a direct Node transport and the resolved local Kong CA for loopback probes. */ export interface LegacyHealthCheckPostgrestGateway { readonly containerId: string; @@ -388,7 +369,7 @@ export function legacyWaitForHealthyServices( ); stillWatching = failures.map((failure) => failure.containerId); if (failures.length > 0) { - return yield* Effect.fail(new LegacyHealthCheckProbeError({ failures })); + return yield* new LegacyHealthCheckProbeError({ failures }); } }); @@ -419,19 +400,17 @@ export function legacyWaitForHealthyServices( opts.images, scans.find((scan) => scan.runtime !== undefined)?.runtime ?? "docker", ); - return yield* Effect.fail( - new LegacyHealthCheckTimeoutError({ - // Go's `assertContainerHealthy` embeds the id INSIDE the message - // (`errors.Errorf("%s container is not running: %s", …)`, - // `status.go:150,154`) — a bare space, not an `<id>: ` prefix, so the - // joined `errors.Join` text is `<id> container is not ready: <health>`. - message: probeError.failures - .map((failure) => `${failure.containerId} ${failure.reason}`) - .join("\n"), - unhealthy: probeError.failures, - ...(suggestion === undefined ? {} : { suggestion }), - }), - ); + return yield* new LegacyHealthCheckTimeoutError({ + // Go's `assertContainerHealthy` embeds the id INSIDE the message + // (`errors.Errorf("%s container is not running: %s", …)`, + // `status.go:150,154`) — a bare space, not an `<id>: ` prefix, so the + // joined `errors.Join` text is `<id> container is not ready: <health>`. + message: probeError.failures + .map((failure) => `${failure.containerId} ${failure.reason}`) + .join("\n"), + unhealthy: probeError.failures, + ...(suggestion === undefined ? {} : { suggestion }), + }); }), ), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts index 6e64b732e5..7b427188da 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/health-check.unit.test.ts @@ -74,14 +74,12 @@ function mockHealthSpawner( // Mirrors a host where only one runtime is installed: `spawnContainerCli` // tries `docker` first and falls back to `podman` when the spawn fails. if (opts.runtime !== undefined && binary !== opts.runtime) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: `${binary}: command not found`, - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${binary}: command not found`, + }); } spawned.push(args); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts index afd0f7ecf5..55ca576083 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/image-prepull.ts @@ -112,12 +112,10 @@ export function legacyEnsureImagesCached( const hint = failures.some(legacyIsDockerDaemonUnreachable) ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` : ""; - return yield* Effect.fail( - new LegacyImagePrepullError({ - message: `${failures.join("\n")}${hint}`, - reason: failureReason, - }), - ); + return yield* new LegacyImagePrepullError({ + message: `${failures.join("\n")}${hint}`, + reason: failureReason, + }); } return resolved; diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts index 03d332ed77..41ff02b6e8 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-container-inputs.ts @@ -29,7 +29,8 @@ * function's, to preserve that pre-existing behavior exactly. */ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Crypto, Effect, FileSystem, Option, Path } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { GlobalFlag } from "effect/unstable/cli"; @@ -38,7 +39,10 @@ import { legacyResolveExperimentalWithProjectEnv } from "../../../shared/legacy/ import { LegacyDbConfigLoadError } from "../legacy-db-config.errors.ts"; import { localDbContainerId } from "../legacy-docker-ids.ts"; import { resolveDockerNetworkMode } from "../../../shared/functions/functions-docker.ts"; -import { legacyViperEnvStringWithProjectFallback } from "../../../shared/legacy/legacy-viper-env.ts"; +import { + LegacyViperEnv, + legacyViperEnvStringWithProjectFallback, +} from "../../../shared/legacy/legacy-viper-env.ts"; import { legacyIsBitbucketPipeline } from "../legacy-bitbucket-pipeline.ts"; import { legacyResolveAuthExternalUrl, @@ -157,7 +161,12 @@ export const legacyBuildLocalDbContainerInputs = ( ): Effect.Effect< LegacyLocalDbContainerInputs, LegacyDbConfigLoadError, - FileSystem.FileSystem | Path.Path | GlobalFlag.Setting.Identifier<"experimental"> | CliArgs + | FileSystem.FileSystem + | Path.Path + | Crypto.Crypto + | GlobalFlag.Setting.Identifier<"experimental"> + | CliArgs + | LegacyViperEnv > => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -170,20 +179,18 @@ export const legacyBuildLocalDbContainerInputs = ( // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep // inside `legacyRunFreshDbSetup`'s fresh-volume setup pipeline — see this field's own doc // comment for why `db reset`'s caller overrides it instead of using it directly. - const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues); + const experimental = yield* legacyResolveExperimentalWithProjectEnv(projectEnvValues).pipe( + Effect.mapError((cause) => mapError(String(cause))), + ); - const values = yield* Effect.try({ - try: () => - legacyResolveLocalConfigValues( - config, - hostname, - workdir, - projectEnvValues, - loaded?.document, - remoteOverrideKeys, - ), - catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), - }); + const values = yield* legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + loaded?.document, + remoteOverrideKeys, + ).pipe(Effect.mapError((cause) => mapError(cause.message))); const bootstrapConfig = yield* legacyResolveDbBootstrapConfig( fs, @@ -200,9 +207,13 @@ export const legacyBuildLocalDbContainerInputs = ( // PRRT_kwDOErm0O86VlqIL; unlike `utils.Config.Hostname`, viper re-reads the dotenv-merged // env fresh at `DockerStart`'s own call site, not at package init). See // {@link resolveDockerNetworkMode}'s doc comment for the full 3-way flag/env precedence. + const envNetworkId = yield* legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + projectEnvValues, + ).pipe(Effect.mapError((cause) => mapError(String(cause)))); const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), - envOverride: legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), + envOverride: envNetworkId, projectId, }); // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal:host-gateway` @@ -211,7 +222,7 @@ export const legacyBuildLocalDbContainerInputs = ( const extraHosts = platform === "linux" ? ["host.docker.internal:host-gateway"] : []; const containerOpts: LegacyContainerOpts = { projectId, - isBitbucketPipeline: legacyIsBitbucketPipeline(), + isBitbucketPipeline: legacyIsBitbucketPipeline(projectEnvValues), workdir, extraHosts, }; @@ -280,17 +291,18 @@ export const legacyBuildLocalDbContainerInputs = ( // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — `legacyRunFreshDbSetup` only // evaluates this Effect when reached AND `realtimeEnabledForSetup`. - jwks: Effect.tryPromise({ - try: () => - legacyResolveLocalJwks( - config, - workdir, - values.jwtSecret, - projectEnvValues, - remoteOverrideKeys, - ), - catch: (cause) => mapError(cause instanceof Error ? cause.message : String(cause)), - }), + jwks: legacyResolveLocalJwks( + config, + workdir, + values.jwtSecret, + projectEnvValues, + remoteOverrideKeys, + ).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => mapError(cause.message)), + ), apiUrl: values.apiUrl, authExternalUrl: legacyResolveAuthExternalUrl( loaded?.document, diff --git a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts index 16b59c6ad9..9c78491e15 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/local-db-running.ts @@ -1,5 +1,6 @@ import { Data, Effect, type FileSystem, Option, type Path, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { LegacyViperEnv } from "../../../shared/legacy/legacy-viper-env.ts"; import { actionability, @@ -74,7 +75,11 @@ export function legacyIsLocalDbRunning( path: Path.Path, workdir: string, configuredProjectId: string | undefined, -): Effect.Effect<boolean, LegacyLocalDbRunningError> { +): Effect.Effect< + boolean, + LegacyLocalDbRunningError, + FileSystem.FileSystem | Path.Path | LegacyViperEnv +> { return Effect.scoped( Effect.gen(function* () { // `warnOnUnresolvedEnv: false` — this doc comment's own `resolveDbToml` note: @@ -141,16 +146,14 @@ export function legacyIsLocalDbRunning( // on a daemon-connection failure (`misc.go:148-154`), so a down daemon // still surfaces the actionable Docker Desktop hint, not just raw stderr. const daemonDown = legacyIsDockerDaemonUnreachable(stderr); - return yield* Effect.fail( - new LegacyLocalDbRunningError({ - message: - stderr.length > 0 - ? `failed to inspect service: ${stderr}` - : "failed to inspect service", - daemonDown, - ...(daemonDown ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } : {}), - }), - ); + return yield* new LegacyLocalDbRunningError({ + message: + stderr.length > 0 + ? `failed to inspect service: ${stderr}` + : "failed to inspect service", + daemonDown, + ...(daemonDown ? { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL } : {}), + }); } return false; }), diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts index a6f79c3c30..4d0290b7cb 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.ts @@ -91,6 +91,7 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; import { legacyIsSqlState } from "../legacy-connect-errors.ts"; import { legacyCheckDbToml } from "../legacy-db-config.toml-read.ts"; +import { LegacyViperEnv } from "../../../shared/legacy/legacy-viper-env.ts"; import { LegacyDbConnection, type LegacyDbSession } from "../legacy-db-connection.service.ts"; import { LegacyDbExecError, type LegacyDbConnectError } from "../legacy-db-connection.errors.ts"; import { LEGACY_CLI_PROJECT_LABEL } from "../legacy-docker-ids.ts"; @@ -270,12 +271,10 @@ export const legacyResetDisconnectClients = Effect.fnUntraced(function* (session legacyIsSqlState(failure.code) && failure.code !== PG_INVALID_CATALOG_NAME ) { - return yield* Effect.fail( - new LegacyDbSetupError({ - message: `failed to disconnect clients: ${failure.message}`, - reason: "database", - }), - ); + return yield* new LegacyDbSetupError({ + message: `failed to disconnect clients: ${failure.message}`, + reason: "database", + }); } } @@ -368,6 +367,7 @@ const legacyRecreateLocalDatabase15 = <E>( | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path + | LegacyViperEnv > => Effect.gen(function* () { const output = yield* Output; @@ -438,6 +438,7 @@ const legacyRecreateLocalDatabase14 = <E>( | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path + | LegacyViperEnv > => Effect.gen(function* () { const { setup, fs, path, workdir } = input; @@ -510,6 +511,7 @@ const legacyRecreateLocalDatabase14 = <E>( Effect.gen(function* () { const session = yield* connectAs("postgres", "postgres"); yield* legacyMigrateAndSeed(session, fs, path, workdir, input.version, { + projectEnv: toml.projectEnv, migrationsEnabled: toml.migrationsEnabled, seed: legacyResolveResetSeedConfig(toml.seed, input.seedFlags, path), experimental: setup.experimental, @@ -544,6 +546,7 @@ export const legacyRecreateLocalDatabase = <E>( | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path + | LegacyViperEnv > => input.setup.majorVersion <= 14 ? legacyRecreateLocalDatabase14(spawner, input) diff --git a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts index 43ee93b2eb..790d55910e 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/reset-local-database.ts @@ -143,11 +143,9 @@ export const legacyResetLocalDatabase = Effect.fnUntraced(function* ( Option.getOrUndefined(cliConfig.projectId), ); if (!running) { - return yield* Effect.fail( - new LegacyResetLocalDbNotRunningError({ - message: `${legacyAqua("supabase start")} is not running.`, - }), - ); + return yield* new LegacyResetLocalDbNotRunningError({ + message: `${legacyAqua("supabase start")} is not running.`, + }); } // resetDatabase: "Resetting local database…" then recreate + migrate + seed. yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts index fd93b34fa8..887b1162f5 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/restart-services.ts @@ -136,7 +136,7 @@ function legacyRestartSatelliteServices( ); const failures = results.filter(Option.isSome).map((result) => result.value); if (failures.length > 0) { - return yield* Effect.fail(new LegacyRestartServicesError({ message: failures.join("\n") })); + return yield* new LegacyRestartServicesError({ message: failures.join("\n") }); } }); } @@ -216,12 +216,10 @@ function legacyReloadKong( const inspected = yield* legacyInspectContainerState(spawner, kongId).pipe(Effect.result); if (Result.isFailure(inspected)) { if (legacyIsContainerNotFoundMessage(inspected.failure.message)) return; - return yield* Effect.fail( - new LegacyKongReloadError({ - message: `failed to inspect kong: ${inspected.failure.message}`, - suggestion: legacyKongRecoverySuggestion(kongId), - }), - ); + return yield* new LegacyKongReloadError({ + message: `failed to inspect kong: ${inspected.failure.message}`, + suggestion: legacyKongRecoverySuggestion(kongId), + }); } if (!inspected.success.running) return; const result = yield* legacyExecCaptureCombined(spawner, kongId, [ @@ -236,15 +234,13 @@ function legacyReloadKong( // error, `errors.New("error executing command")`, for `iresp.ExitCode > 0` — not the // exit code itself. `reloadKong` then wraps it as `failed to reload kong: %w[:\n%s]` // (`reset.go:269-274`), so the `%w` slot is always this exact string, never `exit N`. - return yield* Effect.fail( - new LegacyKongReloadError({ - message: - trimmed.length > 0 - ? `failed to reload kong: error executing command:\n${trimmed}` - : "failed to reload kong: error executing command", - suggestion: legacyKongRecoverySuggestion(kongId), - }), - ); + return yield* new LegacyKongReloadError({ + message: + trimmed.length > 0 + ? `failed to reload kong: error executing command:\n${trimmed}` + : "failed to reload kong: error executing command", + suggestion: legacyKongRecoverySuggestion(kongId), + }); } }); } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts b/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts index fbfe4a79c9..b78e4d832b 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/rollback.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, type FileSystem, type Path } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import type { LegacyContainerIdName } from "../legacy-docker-lifecycle.ts"; @@ -101,7 +101,7 @@ export const legacyRollbackStart = ( deleteVolumes: boolean, workdir: string, debug: boolean, -): Effect.Effect<void, never> => +): Effect.Effect<void, never, FileSystem.FileSystem | Path.Path> => Effect.gen(function* () { // Go's `DockerRemoveAll` prints "Stopping containers..." to the writer its // caller passes (`internal/utils/docker.go:97`); the start-failure path @@ -121,11 +121,13 @@ export const legacyRollbackStart = ( }, debug, ).pipe( - Effect.catch((error) => - Effect.sync(() => { - globalThis.process.stderr.write(`${error.message}\n`); - }), - ), + Effect.matchEffect({ + onFailure: (error) => + Effect.sync(() => { + globalThis.process.stderr.write(`${error.message}\n`); + }), + onSuccess: () => Effect.void, + }), ); yield* legacyCleanupStartSecrets(removedContainers, workdir); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts index be42f1dacd..6c5eca0ba4 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/rollback.unit.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; import { Data, Deferred, Effect, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -85,7 +86,7 @@ describe("legacyRollbackStart", () => { // call) -> container prune -> network prune; no stop calls (empty list) // and no volume prune (deleteVolumes: false). expect(mock.spawned.map((args) => args[0])).toEqual(["ps", "container", "network"]); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("requests a volume prune when deleteVolumes is true", () => { @@ -105,7 +106,7 @@ describe("legacyRollbackStart", () => { "volume", "network", ]); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("swallows a rollback failure, logging it to stderr instead of failing the effect", () => { @@ -124,7 +125,7 @@ describe("legacyRollbackStart", () => { expect(stderr).toHaveBeenCalledTimes(2); expect(stderr).toHaveBeenNthCalledWith(1, "Stopping containers...\n"); expect(stderr).toHaveBeenNthCalledWith(2, "failed to list containers: permission denied\n"); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("logs a generic message to stderr when the underlying failure has no stderr text", () => { @@ -141,7 +142,7 @@ describe("legacyRollbackStart", () => { expect(stderr).toHaveBeenCalledTimes(2); expect(stderr).toHaveBeenNthCalledWith(1, "Stopping containers...\n"); expect(stderr).toHaveBeenNthCalledWith(2, "failed to list containers\n"); - }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index dd31042f69..22244a0baf 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -74,6 +74,7 @@ import { LEGACY_COMPOSE_PROJECT_LABEL, type LegacyContainerError, type LegacyContainerOpts, + type LegacyNetworkCreateError, } from "./container-lifecycle.ts"; import type { LegacyImagePrepullError } from "./image-prepull.ts"; import type { LegacyHealthCheckTimeoutError } from "./health-check.ts"; @@ -251,21 +252,18 @@ export interface LegacyShadowDatabaseHandle { export const legacyCreateShadowDatabase = ( spawner: Spawner, input: LegacyCreateShadowDatabaseInput, -): Effect.Effect<LegacyShadowDatabaseHandle, LegacyShadowDbError> => - Effect.gen(function* () { +): Effect.Effect<LegacyShadowDatabaseHandle, LegacyShadowDbError> => { + const mapContainerError = (cause: LegacyNetworkCreateError | LegacyContainerError) => + new LegacyShadowDbError({ + message: cause.message, + reason: legacyShadowContainerReason(cause.reason), + }); + return Effect.gen(function* () { const labels = { [LEGACY_CLI_PROJECT_LABEL]: input.projectId, [LEGACY_COMPOSE_PROJECT_LABEL]: input.projectId, }; - yield* legacyEnsureNetwork(spawner, input.networkId, labels).pipe( - Effect.mapError( - (cause) => - new LegacyShadowDbError({ - message: cause.message, - reason: legacyShadowContainerReason(cause.reason), - }), - ), - ); + yield* legacyEnsureNetwork(spawner, input.networkId, labels); const spec = legacyBuildShadowPostgresContainerSpec(input); // The shadow container has no name (Docker auto-generates one) and no network alias — // see this module's own header for why that's still enough for the shadow's own one-shot @@ -279,17 +277,10 @@ export const legacyCreateShadowDatabase = ( workdir: input.workdir, extraHosts: input.extraHosts, }; - const containerId = yield* legacyCreateContainer(spawner, spec, containerOpts).pipe( - Effect.mapError( - (cause) => - new LegacyShadowDbError({ - message: cause.message, - reason: legacyShadowContainerReason(cause.reason), - }), - ), - ); + const containerId = yield* legacyCreateContainer(spawner, spec, containerOpts); return { containerId }; - }); + }).pipe(Effect.mapError(mapContainerError)); +}; /** * Port of Go's `utils.DockerRemove(shadow)` as called by every shadow caller diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts index 19bfb04569..3d799c6a8c 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-database.ts @@ -52,6 +52,7 @@ import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSp import type * as HttpClient from "effect/unstable/http/HttpClient"; import { Output } from "../../../shared/output/output.service.ts"; +import { LegacyViperEnv } from "../../../shared/legacy/legacy-viper-env.ts"; import type { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { actionability, @@ -188,6 +189,7 @@ export const legacyStartDatabase = <E>( | LegacyPgDeltaSslProbe | FileSystem.FileSystem | Path.Path + | LegacyViperEnv > => Effect.gen(function* () { const output = yield* Output; @@ -207,12 +209,10 @@ export const legacyStartDatabase = <E>( // Go's `StartDatabase` (`start.go:170-172`): a `--from-backup` restore into an // already-provisioned volume is refused outright, BEFORE any container or network is // created. - return yield* Effect.fail( - new LegacyStartBackupVolumeExistsError({ - message: "backup volume already exists", - suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to remove existing docker volumes.`, - }), - ); + return yield* new LegacyStartBackupVolumeExistsError({ + message: "backup volume already exists", + suggestion: `Run ${legacyAqua("supabase stop --no-backup")} to remove existing docker volumes.`, + }); } // Go's `StartDatabase` (`start.go:168-175`) prints this unconditionally to stderr — Go has @@ -260,7 +260,7 @@ export const legacyStartDatabase = <E>( // BARE — this function has no `--ignore-health-check` knowledge at all, see this module's // header for why that's entirely the caller's concern. if (fromBackup === undefined) { - return yield* Effect.fail(postgresHealthResult.failure); + return yield* postgresHealthResult.failure; } } diff --git a/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts index a7c4b2301f..f42d1fcc59 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/start-local-database.ts @@ -587,9 +587,9 @@ export const legacyStartLocalDatabase = Effect.fnUntraced(function* (fromBackupF ), ); if (studioEnabledForValidation && studioPortForValidation === 0) { - yield* Effect.fail( - new LegacyDbConfigLoadError({ message: "Missing required field in config: studio.port" }), - ); + return yield* new LegacyDbConfigLoadError({ + message: "Missing required field in config: studio.port", + }); } const studioApiUrlForValidation = legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? @@ -612,9 +612,9 @@ export const legacyStartLocalDatabase = Effect.fnUntraced(function* (fromBackupF ), ); if (localSmtpEnabledForValidation && localSmtpPortForValidation === 0) { - yield* Effect.fail( - new LegacyDbConfigLoadError({ message: "Missing required field in config: local_smtp.port" }), - ); + return yield* new LegacyDbConfigLoadError({ + message: "Missing required field in config: local_smtp.port", + }); } // Closes an entire recurring class of gaps in the battery above, rather than adding another @@ -626,20 +626,13 @@ export const legacyStartLocalDatabase = Effect.fnUntraced(function* (fromBackupF // auth.external.* required-field validation, auth.email/notification template reads) to // surface early. Its result is discarded here — only the fail-fast behavior matters — and // `legacyBuildLocalDbContainerInputs` below re-resolves the REAL `values`. - yield* Effect.try({ - try: () => - legacyResolveLocalConfigValues( - config, - hostnameForValidation, - cliConfig.workdir, - projectEnvValues, - loaded?.document, - ), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + yield* legacyResolveLocalConfigValues( + config, + hostnameForValidation, + cliConfig.workdir, + projectEnvValues, + loaded?.document, + ).pipe(Effect.mapError((cause) => new LegacyDbConfigLoadError({ message: cause.message }))); // If the db container is already up, tell the caller and stop here. Runs AFTER the config // load/validation above, matching the established order of loading config before the diff --git a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts index adf44c844f..0b4b507699 100644 --- a/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts +++ b/apps/cli/src/legacy/shared/legacy-bitbucket-pipeline.ts @@ -23,7 +23,7 @@ export const LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY = "BITBUCKET_CLONE_DIR"; * (`docker run`, e.g. `db dump`/`db test`) and `start`'s per-service container * creation (`legacy/shared/db-bootstrap/container-lifecycle.ts`). */ -export function legacyIsBitbucketPipeline(): boolean { - const value = globalThis.process.env[LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY]; +export function legacyIsBitbucketPipeline(environment: Readonly<Record<string, string>>): boolean { + const value = environment[LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY]; return value !== undefined && value.length > 0; } diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts index 541bcf8818..38f47d5ea7 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.parity.unit.test.ts @@ -1,13 +1,11 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; -import { Effect, Exit, FileSystem, Path, Schema } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Layer, Path, Schema } from "effect"; import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; /** * Cross-caller parity coverage: for a table of Go-parity misconfigurations, drives BOTH real @@ -23,31 +21,35 @@ import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts" * pattern from `legacy-local-config-values.unit.test.ts` (same reasoning). */ -function withConfig(content: string) { - const dir = mkdtempSync(join(tmpdir(), "legacy-config-validate-parity-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "config.toml"), content); - return dir; -} +const withConfig = (fs: FileSystem.FileSystem, path: Path.Path, content: string) => + Effect.gen(function* () { + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-config-validate-parity-" }); + const supabaseDir = path.join(dir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), content); + return dir; + }); const readD = (workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* legacyReadDbToml(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, makeLegacyViperEnvLayer()))); /** Drives D's real pipeline and asserts the failure message contains `message`. */ function failsWithD(tomlLines: ReadonlyArray<string>, message: string) { return Effect.gen(function* () { - const dir = withConfig(tomlLines.join("\n")); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* withConfig(fs, path, tomlLines.join("\n")); const exit = yield* readD(dir).pipe(Effect.exit); expect(Exit.isFailure(exit), `D: expected failure containing: ${message}`).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain(message); + expect(Formatter.formatJson(exit.cause)).toContain(message); } - rmSync(dir, { recursive: true, force: true }); - }); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); } const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); @@ -64,9 +66,16 @@ function failsWithL( document?: Readonly<Record<string, unknown>>, ) { const config = baseConfig(overrides); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(message); + return legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, {}, document).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(Formatter.formatJson(exit.cause)).toContain(message); + }), + ), + Effect.provide(BunServices.layer), + ); } interface ParityScenario { @@ -285,7 +294,7 @@ describe("legacyValidateResolvedConfig cross-caller parity (D vs L)", () => { it.effect(`${scenario.name}: D and L fail with the same message`, () => Effect.gen(function* () { yield* failsWithD(scenario.toml, scenario.message); - failsWithL(scenario.overrides, scenario.message, scenario.document); + yield* failsWithL(scenario.overrides, scenario.message, scenario.document); }), ); } diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index 19aefc9849..958050e848 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -1,12 +1,11 @@ -import { statSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; - +import { Data, Effect, FileSystem, Path } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, ErrorActionabilityFingerprintId, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; +import { legacyFilesystemErrorMessage } from "../../shared/legacy/legacy-filesystem-error.ts"; import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** @@ -169,8 +168,14 @@ export function legacyParseGoBool(value: string): boolean | undefined { * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class * is a byte-identical, purely internal refactor. */ -export class LegacyConfigValidateError extends Error { +export class LegacyConfigValidateError extends Data.TaggedError("LegacyConfigValidateError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "LegacyConfigValidateError"; + constructor(message: string) { + super({ message }); + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } @@ -714,13 +719,19 @@ function isValidJson(value: string): boolean { // ── signing keys (path rule: guarded by an isAbsolute check) ── /** Absolute → verbatim; relative → join(workdir, "supabase", p). */ -export function legacyResolveSigningKeysPath(workdir: string, signingKeysPath: string): string { - return isAbsolute(signingKeysPath) ? signingKeysPath : join(workdir, "supabase", signingKeysPath); +export function legacyResolveSigningKeysPath( + path: Path.Path, + workdir: string, + signingKeysPath: string, +): string { + return path.isAbsolute(signingKeysPath) + ? signingKeysPath + : path.join(workdir, "supabase", signingKeysPath); } /** `failed to read signing keys: ${msg(cause)}` */ export function legacySigningKeysReadErrorMessage(cause: unknown): string { - return `failed to read signing keys: ${messageOf(cause)}`; + return `failed to read signing keys: ${legacyFilesystemErrorMessage(cause)}`; } /** `failed to decode signing keys: ${msg(cause)}` */ @@ -743,6 +754,8 @@ export function legacySigningKeysDecodeErrorMessage(cause: unknown): string { * `base` is the caller-resolved project root for both templates and notifications. */ export function legacyResolveEmailTemplateContentPath(args: { + readonly path: Path.Path; + readonly fileSystem: FileSystem.FileSystem; readonly section: "template" | "notification"; readonly name: string; /** Post-env-expand; `""` = absent. */ @@ -750,19 +763,30 @@ export function legacyResolveEmailTemplateContentPath(args: { /** Raw `content` key present in the TOML document. */ readonly contentPresent: boolean; readonly base: string; -}): string | undefined { +}): Effect.Effect<string | void, LegacyConfigValidateError> { if (args.contentPath.length === 0) { if (args.contentPresent) { - throw new LegacyConfigValidateError( - `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + return Effect.fail( + new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + ), ); } - return undefined; + return Effect.void.pipe(Effect.asVoid); } if (args.section === "notification") { - return legacyResolveNotificationContentPath(args.base, args.contentPath); + return legacyResolveNotificationContentPath( + args.path, + args.fileSystem, + args.base, + args.contentPath, + ); } - return isAbsolute(args.contentPath) ? args.contentPath : join(args.base, args.contentPath); + return Effect.succeed( + args.path.isAbsolute(args.contentPath) + ? args.contentPath + : args.path.join(args.base, args.contentPath), + ); } /** @@ -775,19 +799,28 @@ export function legacyResolveEmailTemplateContentPath(args: { * loading, and the Kong template mount builder so every consumer sees the * SAME file. */ -export function legacyResolveNotificationContentPath(base: string, contentPath: string): string { - if (isAbsolute(contentPath)) return contentPath; - const resolved = join(base, contentPath); - if (!legacyIsExistingFile(resolved)) { - const legacyResolved = join(base, "supabase", contentPath); - if (legacyIsExistingFile(legacyResolved)) return legacyResolved; - } - return resolved; -} - -/** A directory at the root-resolved path must not suppress the legacy-file fallback. */ -function legacyIsExistingFile(path: string): boolean { - return statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; +export function legacyResolveNotificationContentPath( + path: Path.Path, + fileSystem: FileSystem.FileSystem, + base: string, + contentPath: string, +): Effect.Effect<string> { + if (path.isAbsolute(contentPath)) return Effect.succeed(contentPath); + const resolved = path.join(base, contentPath); + const isFile = (candidate: string) => + fileSystem.stat(candidate).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); + return isFile(resolved).pipe( + Effect.flatMap((exists) => { + if (exists) return Effect.succeed(resolved); + const legacyResolved = path.join(base, "supabase", contentPath); + return isFile(legacyResolved).pipe( + Effect.map((legacyExists) => (legacyExists ? legacyResolved : resolved)), + ); + }), + ); } /** `Invalid config for auth.email.${section}.${name}.content_path: ${msg(cause)}` */ @@ -796,22 +829,22 @@ export function legacyEmailContentPathReadErrorMessage( name: string, cause: unknown, ): string { - return `Invalid config for auth.email.${section}.${name}.content_path: ${messageOf(cause)}`; + return `Invalid config for auth.email.${section}.${name}.content_path: ${legacyFilesystemErrorMessage(cause)}`; } // ── api.tls cert/key (path rule: NO isAbsolute guard) ── /** Unconditional join(workdir, "supabase", p) — `path.Join` absorbs a leading "/" too. */ -export function legacyResolveApiTlsPath(workdir: string, p: string): string { - return join(workdir, "supabase", p); +export function legacyResolveApiTlsPath(path: Path.Path, workdir: string, p: string): string { + return path.join(workdir, "supabase", p); } /** `failed to read TLS cert: ${msg(cause)}` */ export function legacyApiTlsCertReadErrorMessage(cause: unknown): string { - return `failed to read TLS cert: ${messageOf(cause)}`; + return `failed to read TLS cert: ${legacyFilesystemErrorMessage(cause)}`; } /** `failed to read TLS key: ${msg(cause)}` */ export function legacyApiTlsKeyReadErrorMessage(cause: unknown): string { - return `failed to read TLS key: ${messageOf(cause)}`; + return `failed to read TLS key: ${legacyFilesystemErrorMessage(cause)}`; } diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 80111ef1e0..b11ff07e81 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -292,7 +292,7 @@ function legacyConnectCauseDetail(cause: unknown): string { ? message : typeof code === "string" ? code - : String(cause); + : Object.prototype.toString.call(cause); if (typeof severity === "string" && typeof code === "string" && legacyIsSqlState(code)) { return `server error (${severity}: ${text} (SQLSTATE ${code}))`; } diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index d96433ab23..b1fd639bac 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -76,10 +76,10 @@ export const legacySpawnContainerCliWithRuntime = ( ) => spawner.spawn(ChildProcess.make("docker", args, options)).pipe( Effect.map((handle) => ({ handle, runtime: dockerRuntime })), - Effect.catch(() => + Effect.catchTag("PlatformError", () => spawner.spawn(ChildProcess.make("podman", args, options)).pipe( Effect.map((handle) => ({ handle, runtime: podmanRuntime })), - Effect.catch(() => + Effect.catchTag("PlatformError", () => Effect.fail( new LegacyContainerRuntimeNotFoundError({ message: legacyContainerRuntimeNotFoundMessage, @@ -120,9 +120,9 @@ export const containerCliExitCode = ( podmanArgs?: ReadonlyArray<string>, ) => spawner.exitCode(ChildProcess.make("docker", args, options)).pipe( - Effect.catch(() => + Effect.catchTag("PlatformError", () => spawner.exitCode(ChildProcess.make("podman", podmanArgs ?? args, options)).pipe( - Effect.catch(() => + Effect.catchTag("PlatformError", () => Effect.fail( new LegacyContainerRuntimeNotFoundError({ message: legacyContainerRuntimeNotFoundMessage, @@ -139,7 +139,7 @@ export const containerCliExitCode = ( * `legacy-docker-lifecycle.ts` — every module that spawns `docker`/`podman` and * needs its stdout/stderr as text — stop each defining their own copy. */ -export function legacyCollectText(stream: Stream.Stream<Uint8Array, unknown>) { +export function legacyCollectText<E>(stream: Stream.Stream<Uint8Array, E>) { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -235,9 +235,9 @@ export const legacyContainerCliExitCodeAndStdout = ( stderr: "ignore", } satisfies ChildProcess.CommandOptions; const handle = yield* spawner.spawn(ChildProcess.make("docker", args, options)).pipe( - Effect.catch(() => + Effect.catchTag("PlatformError", () => spawner.spawn(ChildProcess.make("podman", podmanArgs ?? args, options)).pipe( - Effect.catch(() => + Effect.catchTag("PlatformError", () => Effect.fail( new LegacyContainerRuntimeNotFoundError({ message: legacyContainerRuntimeNotFoundMessage, diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts index cd8e1715a7..a7f6dd9dac 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.unit.test.ts @@ -28,14 +28,12 @@ function mockSpawner( spawned.push({ command: cmd, args }); if ((opts.dockerMissing && cmd === "docker") || opts.bothMissing === true) { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: `${cmd} not found`, - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `${cmd} not found`, + }); } const exitDeferred = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index 6fc666c141..c92ca3c960 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -1,9 +1,18 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { afterEach, beforeEach, describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, + Schema, +} from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as Formatter from "effect/Formatter"; import { mockAnalytics, @@ -12,7 +21,11 @@ import { mockTelemetryRuntime, mockTty, } from "../../../tests/helpers/mocks.ts"; -import { LEGACY_VALID_TOKEN, mockLegacyCliConfig } from "../../../tests/helpers/legacy-mocks.ts"; +import { + LEGACY_VALID_TOKEN, + mockLegacyCliConfig, + useLegacyTempWorkdir, +} from "../../../tests/helpers/legacy-mocks.ts"; import { LegacyDebugFlag, LegacyDnsResolverFlag, @@ -30,6 +43,7 @@ import { type LegacyDbSession, type LegacyPgConnInput, } from "./legacy-db-connection.service.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; // `--local` / `--db-url` never touch the Management API stack, so the resolver // builds with simple ambient stubs. The `--linked` sub-flow (login-role, @@ -44,12 +58,113 @@ const mockDbConnection = Layer.succeed(LegacyDbConnection, { connect: () => Effect.die("unexpected connect() in --local/--db-url resolver test"), }); +const testPlatform = ManagedRuntime.make(BunServices.layer); +const testPath = testPlatform.runSync(Path.Path); +const tempRoot = useLegacyTempWorkdir("legacy-db-config-"); + +const PoolerConfigSchema = Schema.Struct({ + identifier: Schema.String, + database_type: Schema.String, + is_using_scram_auth: Schema.Boolean, + db_user: Schema.String, + db_host: Schema.String, + db_port: Schema.Finite, + db_name: Schema.String, + connection_string: Schema.String, + connectionString: Schema.String, + default_pool_size: Schema.Null, + max_client_conn: Schema.Null, + pool_mode: Schema.String, +}); +const encodePoolerConfig = Schema.encodeUnknownSync( + Schema.fromJsonString(Schema.Array(PoolerConfigSchema)), +); +const encodeLoginRoleResponse = Schema.encodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + role: Schema.String, + password: Schema.String, + ttl_seconds: Schema.Finite, + }), + ), +); +const encodeErrorResponse = Schema.encodeUnknownSync( + Schema.fromJsonString(Schema.Struct({ message: Schema.String })), +); + +function join(...paths: ReadonlyArray<string>): string { + return testPath.join(...paths); +} + +type FixtureOperation = Effect.Effect<void, PlatformError, FileSystem.FileSystem>; +const fixtureOperations = new Map<string, Array<FixtureOperation>>(); +let fixtureCounter = 0; + +function fixtureRoot(path: string): string | undefined { + return [...fixtureOperations.keys()] + .filter((root) => path === root || path.startsWith(`${root}/`)) + .sort((a, b) => b.length - a.length)[0]; +} + +function enqueueFixtureOperation(path: string, operation: FixtureOperation): void { + const root = fixtureRoot(path); + if (root === undefined) throw new Error(`fixture root not registered for ${path}`); + fixtureOperations.get(root)?.push(operation); +} + +function flushFixture(path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> { + const root = fixtureRoot(path); + if (root === undefined) return Effect.void; + const operations = fixtureOperations.get(root) ?? []; + fixtureOperations.delete(root); + return Effect.forEach(operations, (operation) => operation).pipe(Effect.asVoid); +} + +function mkdirSync(path: string, options?: { readonly recursive?: boolean }): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }), + ); +} + +function writeFileSync(path: string, data: string): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, data); + }), + ); +} + +function rmSync( + path: string, + options?: { readonly recursive?: boolean; readonly force?: boolean }, +): void { + const root = fixtureRoot(path); + if (root !== undefined) { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }), + ); + } +} + function buildResolver( workdir: string, opts: { readonly projectHost?: string; readonly poolerHost?: string; readonly dbConnection?: Layer.Layer<LegacyDbConnection>; + readonly env?: Readonly<Record<string, string>>; + readonly projectId?: Option.Option<string>; + readonly osUser?: string; } = {}, ) { const deps = Layer.mergeAll( @@ -57,7 +172,11 @@ function buildResolver( workdir, projectHost: opts.projectHost ?? "supabase.co", poolerHost: opts.poolerHost, - projectId: Option.none(), + projectId: + opts.projectId ?? + (opts.env?.SUPABASE_PROJECT_ID === undefined + ? Option.none() + : Option.some(opts.env.SUPABASE_PROJECT_ID)), }), opts.dbConnection ?? mockDbConnection, mockDebugLogger, @@ -65,7 +184,7 @@ function buildResolver( mockAnalytics().layer, mockTelemetryRuntime(), mockTty(), - mockRuntimeInfo(), + mockRuntimeInfo({ osUser: opts.osUser }), Layer.succeed(LegacyProfileFlag, "supabase"), Layer.succeed(LegacyWorkdirFlag, Option.some(workdir)), Layer.succeed(LegacyOutputFlag, Option.none()), @@ -79,12 +198,15 @@ function buildResolver( Layer.provide(BunServices.layer), ), BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: opts.env ?? {} })), + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env: opts.env ?? {} })), ); return legacyDbConfigLayer.pipe(Layer.provide(deps)); } function withWorkdir(toml?: string) { - const dir = mkdtempSync(join(tmpdir(), "legacy-db-config-")); + const dir = join(tempRoot.current, `legacy-db-config-${fixtureCounter++}`); + fixtureOperations.set(dir, []); if (toml !== undefined) { mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync(join(dir, "supabase", "config.toml"), toml); @@ -97,20 +219,30 @@ const resolve = ( flags: LegacyDbConfigFlags, opts?: Parameters<typeof buildResolver>[1], ) => - Effect.gen(function* () { - const resolver = yield* LegacyDbConfigResolver; - return yield* resolver.resolve(flags); - }).pipe(Effect.provide(buildResolver(workdir, opts))); + flushFixture(workdir).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const resolver = yield* LegacyDbConfigResolver; + return yield* resolver.resolve(flags); + }).pipe(Effect.provide(buildResolver(workdir, opts))), + ), + Effect.provide(BunServices.layer), + ); const resolvePoolerFallback = ( workdir: string, flags: LegacyDbConfigFlags, opts?: Parameters<typeof buildResolver>[1], ) => - Effect.gen(function* () { - const resolver = yield* LegacyDbConfigResolver; - return yield* resolver.resolvePoolerFallback(flags); - }).pipe(Effect.provide(buildResolver(workdir, opts))); + flushFixture(workdir).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const resolver = yield* LegacyDbConfigResolver; + return yield* resolver.resolvePoolerFallback(flags); + }).pipe(Effect.provide(buildResolver(workdir, opts))), + ), + Effect.provide(BunServices.layer), + ); const localFlags: LegacyDbConfigFlags = { dbUrl: Option.none(), @@ -129,24 +261,8 @@ const linkedFlags: LegacyDbConfigFlags = { }; describe("legacyDbConfigResolver (local + db-url)", () => { - // The resolver derives the local host from `legacyGetHostname()`, which reads - // SUPABASE_SERVICES_HOSTNAME and DOCKER_HOST. Clear both so the local-host - // assertions are deterministic regardless of the runner's Docker config. - let savedServicesHostname: string | undefined; - let savedDockerHost: string | undefined; - beforeEach(() => { - savedServicesHostname = process.env["SUPABASE_SERVICES_HOSTNAME"]; - savedDockerHost = process.env["DOCKER_HOST"]; - delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - delete process.env["DOCKER_HOST"]; - }); - afterEach(() => { - if (savedServicesHostname === undefined) delete process.env["SUPABASE_SERVICES_HOSTNAME"]; - else process.env["SUPABASE_SERVICES_HOSTNAME"] = savedServicesHostname; - if (savedDockerHost === undefined) delete process.env["DOCKER_HOST"]; - else process.env["DOCKER_HOST"] = savedDockerHost; - }); - + // The resolver derives the local host from `legacyGetHostname()`. Keep + // ambient host settings out of these deterministic connection assertions. it.effect("local mode: uses 127.0.0.1 with config.toml db.port/password and is local", () => { const dir = withWorkdir(["[db]", "port = 55555", 'password = "hunter2"', ""].join("\n")); return resolve(dir, localFlags).pipe( @@ -174,9 +290,10 @@ describe("legacyDbConfigResolver (local + db-url)", () => { it.effect("local mode: honors SUPABASE_SERVICES_HOSTNAME for the connection host", () => { // Dev-container / remote-Docker parity (Go's utils.Config.Hostname). - process.env["SUPABASE_SERVICES_HOSTNAME"] = "host.docker.internal"; const dir = withWorkdir(); - return resolve(dir, localFlags).pipe( + return resolve(dir, localFlags, { + env: { SUPABASE_SERVICES_HOSTNAME: "host.docker.internal" }, + }).pipe( Effect.tap((r) => Effect.sync(() => { expect(r.conn.host).toBe("host.docker.internal"); @@ -261,7 +378,7 @@ describe("legacyDbConfigResolver (local + db-url)", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigParseUrlError"); expect(json).toContain("[REDACTED]"); expect(json).not.toContain("s3cret"); @@ -307,6 +424,113 @@ describe("legacyDbConfigResolver (local + db-url)", () => { ); }); + it.effect("db-url mode: resolves a named libpq service from the injected service file", () => { + const dir = withWorkdir(); + const servicePath = join(dir, "pg_service.conf"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + servicePath, + [ + "[local-test]", + "host=service.example.com", + "port=6544", + "user=service-user", + "password=service-password", + "dbname=service-db", + "", + ].join("\n"), + ); + return resolve(dir, dbUrlFlags("postgresql:///"), { + env: { PGSERVICE: "local-test", PGSERVICEFILE: servicePath }, + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn).toMatchObject({ + host: "service.example.com", + port: 6544, + user: "service-user", + password: "service-password", + database: "service-db", + }); + expect(r.isLocal).toBe(false); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("db-url mode: resolves explicit servicefile and passfile paths", () => { + const dir = withWorkdir(); + const servicePath = join(dir, "explicit-service.conf"); + const passfilePath = join(dir, "explicit.pgpass"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + servicePath, + [ + "[explicit]", + "host=explicit.example.com", + "port=6545", + "user=explicit-user", + "dbname=explicit-db", + "", + ].join("\n"), + ); + writeFileSync( + passfilePath, + "explicit.example.com:6545:explicit-db:explicit-user:explicit-secret\n", + ); + return resolve( + dir, + dbUrlFlags(`service=explicit servicefile=${servicePath} passfile=${passfilePath}`), + ).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn).toMatchObject({ + host: "explicit.example.com", + port: 6545, + user: "explicit-user", + password: "explicit-secret", + database: "explicit-db", + }); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("db-url mode: resolves a password from the injected pgpass file", () => { + const dir = withWorkdir(); + const passfilePath = join(dir, "pgpass"); + mkdirSync(dir, { recursive: true }); + writeFileSync(passfilePath, "db.example.com:5432:app:postgres:passfile-secret\n"); + return resolve(dir, dbUrlFlags("postgresql://postgres@db.example.com:5432/app"), { + env: { PGPASSFILE: passfilePath }, + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn.password).toBe("passfile-secret"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + + it.effect("db-url mode: uses the injected OS user for libpq defaults", () => { + const dir = withWorkdir(); + return resolve(dir, dbUrlFlags("postgresql:///"), { + env: {}, + osUser: "runtime-user", + }).pipe( + Effect.tap((r) => + Effect.sync(() => { + expect(r.conn.user).toBe("runtime-user"); + expect(r.conn.database).toBe("runtime-user"); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect( "db-url mode: a malformed percent escape is a redacted parse error, not a defect", () => { @@ -319,7 +543,7 @@ describe("legacyDbConfigResolver (local + db-url)", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigParseUrlError"); expect(json).toContain("[REDACTED]"); expect(json).not.toContain("p%zz"); @@ -355,19 +579,19 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { "", ].join("\n"), ); - // The linked ref is sourced via the project-ref resolver's env fallback. - process.env["SUPABASE_PROJECT_ID"] = ref; - return resolve(dir, linkedFlags).pipe( + // Inject the linked ref through the highest-precedence linked flag. Environment + // fallback precedence is covered by the dedicated cli-config/project-ref tests; + // this scenario isolates merged-config validation ordering. + return resolve(dir, { ...linkedFlags, linkedProjectRef: Option.some(ref) }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Failed reading config: Invalid db.major_version: 99.", ); } - delete process.env["SUPABASE_PROJECT_ID"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -382,13 +606,13 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { // env and the ref file seeded as a DIRECTORY, the resolver must surface that. const dir = withWorkdir(); mkdirSync(join(dir, "supabase", ".temp", "project-ref"), { recursive: true }); - return resolve(dir, linkedFlags).pipe( + return resolve(dir, linkedFlags, { env: {}, projectId: Option.none() }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("failed to load project ref"); expect(json).not.toContain("Cannot find project ref"); } @@ -411,8 +635,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; const previousFetch = globalThis.fetch; const requests: Array<{ readonly method: string; readonly path: string }> = []; const connections: Array<{ @@ -436,57 +658,58 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { }), }); const fetchMock = Object.assign( - async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { - const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if ( - method === "GET" && - url.pathname === `/v1/projects/${adHocRef}/config/database/pooler` - ) { - return new Response( - JSON.stringify([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, - ); - } - - if (method === "POST" && url.pathname === `/v1/projects/${adHocRef}/cli/login-role`) { - return new Response( - JSON.stringify({ - role: "cli_login_role", - password: "temporary-role-password", - ttl_seconds: 3600, - }), - { status: 201, headers: { "content-type": "application/json" } }, + (input: string | URL | Request, init?: RequestInit): Promise<Response> => + Promise.resolve().then(() => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, ); - } + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); - return new Response(JSON.stringify({ message: "unexpected request" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }, + if ( + method === "GET" && + url.pathname === `/v1/projects/${adHocRef}/config/database/pooler` + ) { + return new Response( + encodePoolerConfig([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + if (method === "POST" && url.pathname === `/v1/projects/${adHocRef}/cli/login-role`) { + return new Response( + encodeLoginRoleResponse({ + role: "cli_login_role", + password: "temporary-role-password", + ttl_seconds: 3600, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(encodeErrorResponse({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), { preconnect: previousFetch.preconnect }, ); - process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; - process.env["SUPABASE_DB_PASSWORD"] = "ambient-linked-password"; globalThis.fetch = fetchMock; return resolve( @@ -496,7 +719,14 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { linkedProjectRef: Option.some(adHocRef), adHocProjectRef: true, }, - { projectHost: "invalid", dbConnection }, + { + projectHost: "invalid", + dbConnection, + env: { + SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-linked-password", + }, + }, ).pipe( Effect.tap((r) => Effect.sync(() => { @@ -540,10 +770,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { Effect.ensuring( Effect.sync(() => { globalThis.fetch = previousFetch; - if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; - else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; - if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; rmSync(dir, { recursive: true, force: true }); }), ), @@ -565,8 +791,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; const previousFetch = globalThis.fetch; const requests: Array<{ readonly method: string; readonly path: string }> = []; const connections: Array<{ @@ -590,59 +814,58 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { }), }); const fetchMock = Object.assign( - async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if ( - method === "GET" && - url.pathname === `/v1/projects/${adHocRef}/config/database/pooler` - ) { - return new Response( - JSON.stringify([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, + (input: string | URL | Request, init?: RequestInit): Promise<Response> => + Promise.resolve().then(() => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, ); - } + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); - if (method === "POST" && url.pathname === `/v1/projects/${adHocRef}/cli/login-role`) { - return new Response( - JSON.stringify({ - role: "cli_login_role", - password: "temporary-role-password", - ttl_seconds: 3600, - }), - { status: 201, headers: { "content-type": "application/json" } }, - ); - } + if ( + method === "GET" && + url.pathname === `/v1/projects/${adHocRef}/config/database/pooler` + ) { + return new Response( + encodePoolerConfig([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${adHocRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } - return new Response(JSON.stringify({ message: "unexpected request" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }, + if (method === "POST" && url.pathname === `/v1/projects/${adHocRef}/cli/login-role`) { + return new Response( + encodeLoginRoleResponse({ + role: "cli_login_role", + password: "temporary-role-password", + ttl_seconds: 3600, + }), + { status: 201, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(encodeErrorResponse({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), { preconnect: previousFetch.preconnect }, ); - process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; - process.env["SUPABASE_DB_PASSWORD"] = "ambient-linked-password"; globalThis.fetch = fetchMock; return resolvePoolerFallback( @@ -652,7 +875,13 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { linkedProjectRef: Option.some(adHocRef), adHocProjectRef: true, }, - { dbConnection }, + { + dbConnection, + env: { + SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-linked-password", + }, + }, ).pipe( Effect.tap((connOpt) => Effect.sync(() => { @@ -698,10 +927,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { Effect.ensuring( Effect.sync(() => { globalThis.fetch = previousFetch; - if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; - else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; - if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; rmSync(dir, { recursive: true, force: true }); }), ), @@ -721,8 +946,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { "postgres://postgres.qrstabcdefghijklmnop:saved-workdir-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres", ); - const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; const previousFetch = globalThis.fetch; const requests: Array<{ readonly method: string; readonly path: string }> = []; const connections: Array<{ @@ -746,46 +969,47 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { }), }); const fetchMock = Object.assign( - async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { - const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if ( - method === "GET" && - url.pathname === `/v1/projects/${linkedRef}/config/database/pooler` - ) { - return new Response( - JSON.stringify([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, + (input: string | URL | Request, init?: RequestInit): Promise<Response> => + Promise.resolve().then(() => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, ); - } + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); - return new Response(JSON.stringify({ message: "unexpected request" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }, + if ( + method === "GET" && + url.pathname === `/v1/projects/${linkedRef}/config/database/pooler` + ) { + return new Response( + encodePoolerConfig([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${linkedRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(encodeErrorResponse({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), { preconnect: previousFetch.preconnect }, ); - process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; - process.env["SUPABASE_DB_PASSWORD"] = "linked-password"; globalThis.fetch = fetchMock; return resolvePoolerFallback( @@ -794,7 +1018,14 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { ...linkedFlags, linkedProjectRef: Option.some(linkedRef), }, - { projectHost: "supabase.co", dbConnection }, + { + projectHost: "supabase.co", + dbConnection, + env: { + SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, + SUPABASE_DB_PASSWORD: "linked-password", + }, + }, ).pipe( Effect.tap((connOpt) => Effect.sync(() => { @@ -824,10 +1055,6 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { Effect.ensuring( Effect.sync(() => { globalThis.fetch = previousFetch; - if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; - else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; - if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; rmSync(dir, { recursive: true, force: true }); }), ), @@ -849,8 +1076,6 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo // so there is no `.temp/project-ref` and no `.temp/pooler-url` to reuse. const dir = withWorkdir(); - const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; const previousFetch = globalThis.fetch; const requests: Array<{ readonly method: string; readonly path: string }> = []; const dbConnection = Layer.succeed(LegacyDbConnection, { @@ -858,45 +1083,44 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo Effect.die("unexpected connect() — the ambient password path never verify-connects"), }); const fetchMock = Object.assign( - async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { - return new Response( - JSON.stringify([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, + (input: string | URL | Request, init?: RequestInit): Promise<Response> => + Promise.resolve().then(() => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, ); - } + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); - return new Response(JSON.stringify({ message: "unexpected request" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }, + if (method === "GET" && url.pathname === `/v1/projects/${ref}/config/database/pooler`) { + return new Response( + encodePoolerConfig([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${ref}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(encodeErrorResponse({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), { preconnect: previousFetch.preconnect }, ); - process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; - process.env["SUPABASE_DB_PASSWORD"] = "ambient-password"; globalThis.fetch = fetchMock; return resolve( @@ -905,7 +1129,14 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo ...linkedFlags, linkedProjectRef: Option.some(ref), }, - { projectHost: "invalid", dbConnection }, + { + projectHost: "invalid", + dbConnection, + env: { + SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-password", + }, + }, ).pipe( Effect.tap((r) => Effect.sync(() => { @@ -931,10 +1162,6 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo Effect.ensuring( Effect.sync(() => { globalThis.fetch = previousFetch; - if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; - else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; - if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; rmSync(dir, { recursive: true, force: true }); }), ), @@ -957,8 +1184,6 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo `postgres://postgres.${linkedRef}:saved-workdir-password@stale.pooler.supabase.com:6543/postgres`, ); - const previousAccessToken = process.env["SUPABASE_ACCESS_TOKEN"]; - const previousPassword = process.env["SUPABASE_DB_PASSWORD"]; const previousFetch = globalThis.fetch; const requests: Array<{ readonly method: string; readonly path: string }> = []; const dbConnection = Layer.succeed(LegacyDbConnection, { @@ -966,48 +1191,47 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo Effect.die("unexpected connect() — the ambient password path never verify-connects"), }); const fetchMock = Object.assign( - async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { - const url = new URL( - typeof input === "string" || input instanceof URL ? input : input.url, - ); - const method = init?.method ?? (input instanceof Request ? input.method : "GET"); - requests.push({ method, path: url.pathname }); - - if ( - method === "GET" && - url.pathname === `/v1/projects/${targetRef}/config/database/pooler` - ) { - return new Response( - JSON.stringify([ - { - identifier: "primary", - database_type: "PRIMARY", - is_using_scram_auth: true, - db_user: "postgres", - db_host: "db.example", - db_port: 5432, - db_name: "postgres", - connection_string: `postgres://postgres.${targetRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - connectionString: `postgres://postgres.${targetRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, - default_pool_size: null, - max_client_conn: null, - pool_mode: "transaction", - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, + (input: string | URL | Request, init?: RequestInit): Promise<Response> => + Promise.resolve().then(() => { + const url = new URL( + typeof input === "string" || input instanceof URL ? input : input.url, ); - } + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + requests.push({ method, path: url.pathname }); - return new Response(JSON.stringify({ message: "unexpected request" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - }, + if ( + method === "GET" && + url.pathname === `/v1/projects/${targetRef}/config/database/pooler` + ) { + return new Response( + encodePoolerConfig([ + { + identifier: "primary", + database_type: "PRIMARY", + is_using_scram_auth: true, + db_user: "postgres", + db_host: "db.example", + db_port: 5432, + db_name: "postgres", + connection_string: `postgres://postgres.${targetRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + connectionString: `postgres://postgres.${targetRef}:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres`, + default_pool_size: null, + max_client_conn: null, + pool_mode: "transaction", + }, + ]), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + + return new Response(encodeErrorResponse({ message: "unexpected request" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }), { preconnect: previousFetch.preconnect }, ); - process.env["SUPABASE_ACCESS_TOKEN"] = LEGACY_VALID_TOKEN; - process.env["SUPABASE_DB_PASSWORD"] = "ambient-password"; globalThis.fetch = fetchMock; return resolve( @@ -1016,7 +1240,14 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo ...linkedFlags, linkedProjectRef: Option.some(targetRef), }, - { projectHost: "invalid", dbConnection }, + { + projectHost: "invalid", + dbConnection, + env: { + SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, + SUPABASE_DB_PASSWORD: "ambient-password", + }, + }, ).pipe( Effect.tap((r) => Effect.sync(() => { @@ -1043,10 +1274,6 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo Effect.ensuring( Effect.sync(() => { globalThis.fetch = previousFetch; - if (previousAccessToken === undefined) delete process.env["SUPABASE_ACCESS_TOKEN"]; - else process.env["SUPABASE_ACCESS_TOKEN"] = previousAccessToken; - if (previousPassword === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = previousPassword; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1068,13 +1295,17 @@ describe("legacyDbConfigResolver (--project-ref pooler fetch decoupled from adHo mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); writeFileSync(join(dir, "supabase", ".temp", "project-ref"), ref); - return resolve(dir, linkedFlags, { projectHost: "invalid" }).pipe( + return resolve(dir, linkedFlags, { + projectHost: "invalid", + env: {}, + projectId: Option.none(), + }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigIpv6Error"); expect(json).toContain( `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, diff --git a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts index 3cc229136f..f7fca01423 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.layer.ts @@ -1,6 +1,16 @@ import * as net from "node:net"; import { BunServices } from "@effect/platform-bun"; -import { Duration, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { + Config, + ConfigProvider, + Crypto, + Duration, + Effect, + FileSystem, + Layer, + Option, + Path, +} from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; @@ -17,7 +27,7 @@ import { LegacyWorkdirFlag, } from "../../shared/legacy/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; -import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { RuntimeInfo, type RuntimeInfoShape } from "../../shared/runtime/runtime-info.service.ts"; import { Tty } from "../../shared/runtime/tty.service.ts"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; @@ -33,10 +43,13 @@ import { } from "./legacy-management-api-runtime.layer.ts"; import * as Errors from "./legacy-db-config.errors.ts"; import { + LEGACY_PARSE_ENV_NAMES, + legacyConnectionStringFilePaths, legacyLayeredParseEnv, legacyPoolerConfigFromConnectionString, parseLegacyConnectionString, redactLegacyConnectionString, + type LegacyDbConfigParseRuntime, } from "./legacy-db-config.parse.ts"; import { LegacyDbConfigResolver, type LegacyDbConfigError } from "./legacy-db-config.service.ts"; import { legacyLoadProjectEnv, legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; @@ -44,6 +57,7 @@ import type { LegacyDbConfigFlags } from "./legacy-db-config.types.ts"; import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { mapLegacyHttpError } from "./legacy-http-errors.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); @@ -51,6 +65,58 @@ const MAX_RETRIES = 8; const BACKOFF_INITIAL = Duration.seconds(3); const BACKOFF_MAX = Duration.seconds(60); +/** + * Materialize libpq's host/filesystem/account defaults at the Effect boundary. + * The parser remains pure: service files and pgpass contents are read through + * the injected FileSystem, and the runtime receives the already-resolved + * project/shell environment rather than consulting process globals. + */ +export const legacyMakeDbConfigParseRuntime = ( + fs: FileSystem.FileSystem, + path: Path.Path, + runtimeInfo: RuntimeInfoShape, + env: (name: string) => string | undefined, + extraFilePaths: ReadonlyArray<string> = [], +): Effect.Effect<LegacyDbConfigParseRuntime> => + Effect.gen(function* () { + const homeDirectory = runtimeInfo.homeDir; + const defaultServiceFilePath = + homeDirectory.length > 0 ? path.join(homeDirectory, ".pg_service.conf") : undefined; + const defaultPassfilePath = + homeDirectory.length > 0 ? path.join(homeDirectory, ".pgpass") : undefined; + const serviceFilePath = env("PGSERVICEFILE") || defaultServiceFilePath; + const passfilePath = env("PGPASSFILE") || defaultPassfilePath; + const files = new Map<string, string>(); + const candidates = [serviceFilePath, passfilePath, ...extraFilePaths].filter( + (value): value is string => value !== undefined, + ); + for (const candidate of candidates) { + if (files.has(candidate)) continue; + const contents = yield* fs.readFileString(candidate).pipe(Effect.option); + if (Option.isSome(contents)) files.set(candidate, contents.value); + } + + let defaultHost = "localhost"; + if (runtimeInfo.platform !== "win32") { + for (const candidate of ["/var/run/postgresql", "/private/tmp", "/tmp"]) { + if (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + defaultHost = candidate; + break; + } + } + } + + return { + defaultHost, + defaultServiceFilePath, + homeDirectory, + join: path.join, + files, + serviceFiles: files, + osUser: runtimeInfo.osUser, + }; + }); + const loginRoleErrorMapper = mapLegacyHttpError({ networkError: Errors.LegacyDbConfigLoginRoleNetworkError, statusError: Errors.LegacyDbConfigLoginRoleStatusError, @@ -212,9 +278,13 @@ const resolveDbPassword = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); + const env = yield* LegacyViperEnv; + const shellPassword = yield* env + .get("SUPABASE_DB_PASSWORD") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); return ( Option.getOrUndefined(passwordFlag) ?? - process.env["SUPABASE_DB_PASSWORD"] ?? + Option.getOrUndefined(shellPassword) ?? projectEnv["SUPABASE_DB_PASSWORD"] ?? "" ); @@ -391,12 +461,10 @@ export const legacyResolveLinkedConn = Effect.fnUntraced(function* ( resolveVaultSecrets, ); if (Option.isNone(poolerConn)) { - return yield* Effect.fail( - new Errors.LegacyDbConfigIpv6Error({ - message: "IPv6 is not supported on your current network", - suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, - }), - ); + return yield* new Errors.LegacyDbConfigIpv6Error({ + message: "IPv6 is not supported on your current network", + suggestion: `Run supabase link --project-ref ${ref} to setup IPv4 connection.`, + }); } return poolerConn.value; }); @@ -407,9 +475,17 @@ export const legacyDbConfigLayer = Layer.effect( const cliConfig = yield* LegacyCliConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const runtimeInfo = yield* RuntimeInfo; const debug = yield* LegacyDebugLogger; const output = yield* Output; const dbConn = yield* LegacyDbConnection; + const crypto = yield* Crypto.Crypto; + const viperEnv = yield* LegacyViperEnv; + // The lazy linked runtime rebuilds `legacyCliConfigLayer`, whose Config + // reads use Effect's ambient ConfigProvider. Capture the provider from the + // caller so test/embedded runtimes keep their explicit configuration rather + // than silently falling back to process.env when the nested layer is built. + const configProvider = yield* ConfigProvider.ConfigProvider; // `legacyResolveLinkedConn`/`resolvePoolerConn` (etc.) are standalone functions // that yield their own `FileSystem`/`Path`/`LegacyDebugLogger`/`Output`/ // `LegacyDbConnection` (so bootstrap can call them directly from its own @@ -424,6 +500,8 @@ export const legacyDbConfigLayer = Layer.effect( Layer.succeed(LegacyDebugLogger, debug), Layer.succeed(Output, output), Layer.succeed(LegacyDbConnection, dbConn), + Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(LegacyViperEnv, viperEnv), ); // Profile context for the connect-failure suggestion (`SetConnectSuggestion` @@ -461,6 +539,7 @@ export const legacyDbConfigLayer = Layer.effect( // platform-API factory + linked-project cache (Go's single root-context // `sync.Once`). Provided to this layer by each command runtime. Layer.succeed(LegacyIdentityStitch, yield* LegacyIdentityStitch), + Layer.succeed(ConfigProvider.ConfigProvider, configProvider), // Optional (absent in handler tests): the lazy rebuild of // `legacyCliConfigLayer` reads it for explicit `--profile` detection, so // the nested resolution matches the outer layer's. @@ -494,31 +573,42 @@ export const legacyDbConfigLayer = Layer.effect( // `utils.Config.Hostname` (`GetHostname()`): honors // `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or // remote-Docker setups, defaulting to 127.0.0.1. - const localHost = legacyGetHostname(); + const localHost = yield* legacyGetHostname.pipe(Effect.provide(localAmbientServices)); // --db-url (direct) takes precedence. if (flags.connType === "db-url" && Option.isSome(flags.dbUrl)) { const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { resolveVaultSecrets, - }); + }).pipe(Effect.provide(localAmbientServices)); // Go's direct path runs `LoadConfig` before `pgconn.ParseConfig`, // so the project `.env*` files // populate the environment that the libpq `PG*` fallbacks read. Layer the // project env under the shell env (`legacyLoadProjectEnv` already excludes // shell-set keys, so the shell still wins) and feed it to the parser. const projectEnv = yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); - const conn = parseLegacyConnectionString( - flags.dbUrl.value, - legacyLayeredParseEnv(projectEnv), + const shellValues = yield* Effect.forEach(LEGACY_PARSE_ENV_NAMES, (name) => + viperEnv.get(name), ); + const shellEnv: Record<string, string> = {}; + for (const [index, value] of shellValues.entries()) { + const name = LEGACY_PARSE_ENV_NAMES[index]; + if (name !== undefined && Option.isSome(value)) shellEnv[name] = value.value; + } + const parseEnv = legacyLayeredParseEnv(projectEnv, shellEnv); + const parseRuntime = yield* legacyMakeDbConfigParseRuntime( + fs, + path, + runtimeInfo, + parseEnv, + legacyConnectionStringFilePaths(flags.dbUrl.value), + ); + const conn = parseLegacyConnectionString(flags.dbUrl.value, parseEnv, parseRuntime); if (conn === undefined) { - return yield* Effect.fail( - new Errors.LegacyDbConfigParseUrlError({ - // Redact the password component before echoing the URL back - // (CWE-209): a malformed `--db-url` often still carries a secret. - message: `failed to parse connection string: ${redactLegacyConnectionString(flags.dbUrl.value)}`, - }), - ); + return yield* new Errors.LegacyDbConfigParseUrlError({ + // Redact the password component before echoing the URL back + // (CWE-209): a malformed `--db-url` often still carries a secret. + message: `failed to parse connection string: ${redactLegacyConnectionString(flags.dbUrl.value)}`, + }); } const isLocal = isLocalDatabase( conn.host, @@ -568,7 +658,7 @@ export const legacyDbConfigLayer = Layer.effect( // (or run side effects ahead of) the real config error. yield* legacyReadDbToml(fs, path, cliConfig.workdir, ref, { resolveVaultSecrets, - }); + }).pipe(Effect.provide(localAmbientServices)); const resolved = yield* legacyResolveLinkedConn( ref, cliConfig.workdir, @@ -611,7 +701,7 @@ export const legacyDbConfigLayer = Layer.effect( // --local (default). const tomlValues = yield* legacyReadDbToml(fs, path, cliConfig.workdir, undefined, { resolveVaultSecrets, - }); + }).pipe(Effect.provide(localAmbientServices)); return { conn: { host: localHost, @@ -674,11 +764,25 @@ export const legacyDbConfigLayer = Layer.effect( ...conn, suggestionContext, }); + const mapConfigError = ( + cause: LegacyDbConfigError | Config.ConfigError, + ): LegacyDbConfigError => + cause instanceof Config.ConfigError + ? new Errors.LegacyDbConfigLoadError({ message: cause.message }) + : cause; return LegacyDbConfigResolver.of({ resolve: (flags) => - resolve(flags).pipe(Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) }))), + resolve(flags).pipe( + Effect.provide(localAmbientServices), + Effect.map((r) => ({ ...r, conn: withSuggestion(r.conn) })), + Effect.mapError(mapConfigError), + ), resolvePoolerFallback: (flags) => - resolvePoolerFallback(flags).pipe(Effect.map(Option.map(withSuggestion))), + resolvePoolerFallback(flags).pipe( + Effect.provide(localAmbientServices), + Effect.map(Option.map(withSuggestion)), + Effect.mapError(mapConfigError), + ), }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.parse.ts b/apps/cli/src/legacy/shared/legacy-db-config.parse.ts index cfd8843a01..7e9315152a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.parse.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.parse.ts @@ -1,9 +1,6 @@ -import { existsSync } from "node:fs"; -import { homedir, userInfo } from "node:os"; -import { join } from "node:path"; import { getDomain } from "tldts"; import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; -import { legacyPgpassPassword } from "./legacy-pgpass.ts"; +import { legacyPgpassPassword, type LegacyPgpassRuntime } from "./legacy-pgpass.ts"; import { legacyServiceSettings } from "./legacy-pgservicefile.ts"; /** Go's `pgconn` default direct Postgres port. */ @@ -14,12 +11,39 @@ const DIRECT_PORT = 5432; * layer the project `.env*` files under the shell environment, mirroring Go's * `LoadConfig` (`godotenv.Load`) populating `os.Environ` before `pgconn.ParseConfig` * reads `PGHOST`/`PGPASSWORD`/`PGSSLMODE`/… (`internal/utils/flags/db_url.go:59-68`). - * Defaults to `process.env` so the pure call sites (and the pooler path, whose - * connection string is fully specified) keep their existing behavior. + * Callers provide the already-resolved shell/project environment explicitly. The + * parser itself is pure and never reads process-global environment state. */ export type LegacyParseEnv = (name: string) => string | undefined; -const processEnv: LegacyParseEnv = (name) => process.env[name]; +/** Explicit data acquired by the Effect composition boundary before parsing. */ +export interface LegacyDbConfigParseRuntime extends LegacyPgpassRuntime { + readonly defaultHost?: string; + readonly defaultServiceFilePath?: string; + readonly serviceFiles: ReadonlyMap<string, string>; + readonly osUser?: string; +} + +/** PG* variables consulted by libpq parsing; callers may resolve these through Config. */ +export const LEGACY_PARSE_ENV_NAMES = [ + "PGAPPNAME", + "PGCONNECT_TIMEOUT", + "PGDATABASE", + "PGHOST", + "PGPASSFILE", + "PGPASSWORD", + "PGPORT", + "PGSERVICE", + "PGSERVICEFILE", + "PGSSLCERT", + "PGSSLKEY", + "PGSSLMODE", + "PGSSLROOTCERT", + "PGSSLPASSWORD", + "PGUSER", +] as const; + +const emptyParseEnv: LegacyParseEnv = () => undefined; /** * The `sslmode` values pgconn's `configTLS` accepts; any other value is a parse @@ -144,12 +168,8 @@ function libpqEnv(env: LegacyParseEnv, name: string): string | undefined { * common unix-socket directory, else `localhost`; Windows always uses * `localhost`. `PGHOST` (applied by the callers) takes priority over this. */ -function defaultLibpqHost(): string { - if (process.platform === "win32") return "localhost"; - for (const candidate of ["/var/run/postgresql", "/private/tmp", "/tmp"]) { - if (existsSync(candidate)) return candidate; - } - return "localhost"; +function defaultLibpqHost(runtime: LegacyDbConfigParseRuntime | undefined): string { + return runtime?.defaultHost ?? "localhost"; } /** @@ -200,9 +220,10 @@ function libpqConnectTimeout( const SERVICE_RESOLUTION_FAILED = Symbol("service-resolution-failed"); /** libpq's default service file (`~/.pg_service.conf`); `PGSERVICEFILE` overrides. */ -function defaultServiceFilePath(): string | undefined { - const home = homedir(); - return home.length > 0 ? join(home, ".pg_service.conf") : undefined; +function defaultServiceFilePath( + runtime: LegacyDbConfigParseRuntime | undefined, +): string | undefined { + return runtime?.defaultServiceFilePath; } /** @@ -226,6 +247,7 @@ function resolveServiceSettings( connStringService: string | null | undefined, connStringServicefile: string | undefined, env: LegacyParseEnv, + runtime: LegacyDbConfigParseRuntime | undefined, ): Map<string, string> | typeof SERVICE_RESOLUTION_FAILED | undefined { const service = connStringService !== null && connStringService !== undefined @@ -246,11 +268,13 @@ function resolveServiceSettings( const servicefile = connStringServicefile !== undefined ? connStringServicefile - : (libpqEnv(env, "PGSERVICEFILE") ?? defaultServiceFilePath()); + : (libpqEnv(env, "PGSERVICEFILE") ?? defaultServiceFilePath(runtime)); if (servicefile === undefined || servicefile.length === 0) { return SERVICE_RESOLUTION_FAILED; } - return legacyServiceSettings(service, servicefile) ?? SERVICE_RESOLUTION_FAILED; + return ( + legacyServiceSettings(service, servicefile, runtime?.serviceFiles) ?? SERVICE_RESOLUTION_FAILED + ); } /** @@ -291,11 +315,12 @@ function resolveLibpqPassword( user: string, env: LegacyParseEnv, passfile: string | undefined, + runtime: LegacyDbConfigParseRuntime | undefined, ): string { const resolved = connStringPassword ?? libpqEnv(env, "PGPASSWORD") ?? ""; return resolved.length > 0 ? resolved - : legacyPgpassPassword(host, port, database, user, env, passfile); + : legacyPgpassPassword(host, port, database, user, env, passfile, runtime); } /** @@ -386,7 +411,8 @@ function parseHostPortSegment(segment: string): { host: string; port: string } { */ export function parseLegacyConnectionString( value: string, - env: LegacyParseEnv = processEnv, + env: LegacyParseEnv = emptyParseEnv, + runtime?: LegacyDbConfigParseRuntime, ): LegacyPgConnInput | undefined { const trimmed = value.trim(); // Match pgconn's dispatch (`config.go:236`): only a literal `postgres://` / @@ -395,9 +421,32 @@ export function parseLegacyConnectionString( // to the DSN parser, which rejects it (no `key=value`) → the caller surfaces a // redacted parse error rather than connecting to a bogus host. if (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://")) { - return parseUrlConnectionString(value, env); + return parseUrlConnectionString(value, env, runtime); + } + return parseKeywordValueDsn(trimmed, env, runtime); +} + +/** + * Return non-empty service/passfile paths explicitly supplied by a connection + * string. This reuses the same URL and keyword/value syntax parsers as + * `parseLegacyConnectionString`; callers use the paths to preload files before + * invoking the pure connection parser. + */ +export function legacyConnectionStringFilePaths(value: string): ReadonlyArray<string> { + const trimmed = value.trim(); + if (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://")) { + const parsed = parseLegacyUrl(trimmed); + if (parsed === undefined) return []; + return [ + parsed.url.searchParams.get("servicefile"), + parsed.url.searchParams.get("passfile"), + ].filter((path): path is string => path !== null && path.length > 0); } - return parseKeywordValueDsn(trimmed, env); + const settings = parseKeywordValueSettings(trimmed); + if (settings === undefined) return []; + return [settings.get("servicefile"), settings.get("passfile")].filter( + (path): path is string => path !== undefined && path.length > 0, + ); } /** @@ -406,8 +455,9 @@ export function parseLegacyConnectionString( */ export function legacyLayeredParseEnv( projectEnv: Readonly<Record<string, string>>, + shellEnv: Readonly<Record<string, string>> = {}, ): LegacyParseEnv { - return (name) => process.env[name] ?? projectEnv[name]; + return (name) => shellEnv[name] ?? projectEnv[name]; } export type LegacyPoolerConfigResult = @@ -426,7 +476,7 @@ export function legacyPoolerConfigFromConnectionString( expectedPoolerHost: string, ): LegacyPoolerConfigResult { const sanitized = connectionString.replaceAll("[YOUR-PASSWORD]", ""); - const parsed = parseLegacyConnectionString(sanitized); + const parsed = parseLegacyConnectionString(sanitized, emptyParseEnv); if (parsed === undefined) { return { _tag: "invalid", reason: "failed to parse pooler URL" }; } @@ -467,29 +517,27 @@ export function legacyPoolerConfigFromConnectionString( }; } -/** Parse the WHATWG `postgres(ql)://` URL form. */ -function parseUrlConnectionString( - value: string, - env: LegacyParseEnv, -): LegacyPgConnInput | undefined { +interface LegacyParsedUrl { + readonly url: URL; + readonly userinfoRaw: string; + readonly segments: ReadonlyArray<string>; + readonly useHandSplit: boolean; +} + +/** Normalize a Postgres URL for WHATWG parsing while preserving libpq host lists. */ +function parseLegacyUrl(value: string): LegacyParsedUrl | undefined { const trimmed = value.trim(); - // pgconn accepts libpq multi-host failover URLs (`postgres://h1:5432,h2:5433/db`, - // `config.go:166,326-362`), which WHATWG `new URL()` rejects (the comma'd - // host:port is not a valid authority). Hand-extract the authority so we can split - // the host list ourselves, then normalize the URL down to its first host so - // `new URL()` still parses the userinfo, path, and query exactly as before. + // pgconn accepts libpq multi-host failover URLs (`postgres://h1:5432,h2:5433/db`), + // which WHATWG `new URL()` rejects. Hand-extract the authority so the parser can + // split the host list while WHATWG still handles userinfo, path, and query fields. const authority = legacyUrlAuthority(trimmed); - // Go's `net/url` splits userinfo from host on the last `@`; literal `@` in a - // password must be percent-encoded, so the last `@` is the real boundary. const atIdx = authority.lastIndexOf("@"); const userinfoRaw = atIdx === -1 ? "" : authority.slice(0, atIdx); const hostPortRaw = atIdx === -1 ? authority : authority.slice(atIdx + 1); const segments = splitHostPortList(hostPortRaw); const multiHost = segments.length > 1; - // pgconn accepts a port-only authority (`postgres://:5433/db`): `net.SplitHostPort` - // yields an empty host + the port, so the host falls back to PGHOST/default while - // the port is kept (`config.go:464-488`). WHATWG `new URL()` throws on an empty - // host with a port, so route that through the same hand-split path as multi-host. + // pgconn accepts a port-only authority (`postgres://:5433/db`), which WHATWG + // rejects; substitute a placeholder while retaining the parsed port below. const firstSegmentHost = parseHostPortSegment(segments[0]!).host; const emptyHostAuthority = !multiHost && firstSegmentHost.length === 0 && hostPortRaw.length > 0; const useHandSplit = multiHost || emptyHostAuthority; @@ -497,10 +545,6 @@ function parseUrlConnectionString( let normalized = trimmed; if (useHandSplit) { const authorityStart = trimmed.indexOf("://") + 3; - // Substitute a placeholder host so `new URL()` can parse the userinfo/path/query; - // the real host(s)/port(s) come from the hand-split segments below. A non-empty - // first segment (multi-host) is reused verbatim; an empty host gets a literal - // placeholder (never read — structural host/port override it). const placeholderHost = firstSegmentHost.length > 0 ? segments[0]! : "placeholder.invalid"; const newAuthority = atIdx === -1 ? placeholderHost : `${authority.slice(0, atIdx + 1)}${placeholderHost}`; @@ -510,12 +554,22 @@ function parseUrlConnectionString( trimmed.slice(authorityStart + authority.length); } - let url: URL; try { - url = new URL(normalized); + return { url: new URL(normalized), userinfoRaw, segments, useHandSplit }; } catch { return undefined; } +} + +/** Parse the WHATWG `postgres(ql)://` URL form. */ +function parseUrlConnectionString( + value: string, + env: LegacyParseEnv, + runtime: LegacyDbConfigParseRuntime | undefined, +): LegacyPgConnInput | undefined { + const parsed = parseLegacyUrl(value); + if (parsed === undefined) return undefined; + const { url, userinfoRaw, segments, useHandSplit } = parsed; try { // `decodeURIComponent` throws on a malformed percent escape (e.g. `p%zz`). // Keep it inside the try so a bad escape yields a normal parse failure @@ -538,6 +592,7 @@ function parseUrlConnectionString( query.get("service"), query.get("servicefile") ?? undefined, env, + runtime, ); if (serviceSettings === SERVICE_RESOLUTION_FAILED) { return undefined; @@ -553,7 +608,7 @@ function parseUrlConnectionString( ? userQuery : structuralUser.length > 0 ? structuralUser - : (svc("user") ?? defaultOsUser(env)); + : (svc("user") ?? defaultOsUser(env, runtime)); // libpq fills `sslmode` from the service, then `PGSSLMODE`, when the connection // string omits it (pgconn's merge order), before the TLS-mode default. const sslmode = @@ -622,7 +677,7 @@ function parseUrlConnectionString( ? hostQuery : structuralHosts.length > 0 ? structuralHosts.join(",") - : (svc("host") ?? libpqEnv(env, "PGHOST") ?? defaultLibpqHost()); + : (svc("host") ?? libpqEnv(env, "PGHOST") ?? defaultLibpqHost(runtime)); // pgconn copies a `?port=` query value verbatim into `settings["port"]` and the // fallback builder splits it on commas, parsing each segment (`config.go:326-340`), // so a multi-host URL may carry a comma-separated port list (`?port=5432,5433`). @@ -687,6 +742,7 @@ function parseUrlConnectionString( user, env, passfile, + runtime, ); return { host: primary.host, @@ -713,7 +769,7 @@ function parseUrlConnectionString( * escapes. Unknown keywords are ignored. Defaults mirror libpq/pgconn: the user * falls back to the OS account, the database to the user, and the port to 5432. */ -function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnInput | undefined { +function parseKeywordValueSettings(value: string): Map<string, string> | undefined { const params = new Map<string, string>(); const n = value.length; let i = 0; @@ -766,6 +822,16 @@ function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnI // aliases share one settings slot and the last occurrence in the DSN wins. params.set(key === "dbname" ? "database" : key, val); } + return params; +} + +function parseKeywordValueDsn( + value: string, + env: LegacyParseEnv, + runtime: LegacyDbConfigParseRuntime | undefined, +): LegacyPgConnInput | undefined { + const params = parseKeywordValueSettings(value); + if (params === undefined) return undefined; // Omitted fields fall back to libpq `PG*` env vars and then the libpq defaults, // matching pgconn's `mergeSettings(defaultSettings, envSettings, connStringSettings)`. // A libpq DSN also accepts comma-separated multi-host failover @@ -776,6 +842,7 @@ function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnI params.get("service"), params.get("servicefile"), env, + runtime, ); if (serviceSettings === SERVICE_RESOLUTION_FAILED) return undefined; const svc = (key: string): string | undefined => serviceValue(serviceSettings, key); @@ -785,7 +852,7 @@ function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnI // so a `hostaddr`-only DSN dials `defaultHost()` (`defaults.go:15`), never the address. // Don't use `hostaddr` as a host fallback (it would dial a different endpoint than Go). const hostString = - params.get("host") ?? svc("host") ?? libpqEnv(env, "PGHOST") ?? defaultLibpqHost(); + params.get("host") ?? svc("host") ?? libpqEnv(env, "PGHOST") ?? defaultLibpqHost(runtime); // Explicit empty/non-numeric `port=` is a parse error (pgconn's `parsePort`); an // absent `port` falls back to the service, then `PGPORT`, then the libpq default. const portParam = params.get("port"); @@ -800,7 +867,7 @@ function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnI const hostList = buildLegacyHostList(hostString, portString); if (hostList === undefined || hostList.length === 0) return undefined; const primary = hostList[0]!; - const user = params.get("user") ?? svc("user") ?? defaultOsUser(env); + const user = params.get("user") ?? svc("user") ?? defaultOsUser(env, runtime); // `dbname` was remapped to `database` at parse time (last-wins alias), so read // only `database` here. A present value (even empty) overrides service/env. const database = @@ -844,6 +911,7 @@ function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnI user, env, passfile, + runtime, ); return { host: primary.host, @@ -873,21 +941,15 @@ function parseKeywordValueDsn(value: string, env: LegacyParseEnv): LegacyPgConnI * when non-empty, `config.go:436-441`), so an empty `PGUSER` falls through to the OS * account. The OS account is `user.Current().Username` (`defaults.go:21-23`) — the * passwd entry for the effective uid, **not** the `$USER`/`$USERNAME` env vars (those - * are never consulted by pgconn; only `PGUSER` is an env override). Node's - * `os.userInfo().username` is the faithful analogue; it can throw when there is no - * passwd entry, mirroring Go's ignored-error path → the `"postgres"` guard. + * are never consulted by pgconn; only `PGUSER` is an env override). The composition + * boundary supplies the effective OS username when available; the parser falls back + * to `"postgres"` when the host runtime has no account information. */ -function osAccountUsername(): string | undefined { - try { - const name = userInfo().username; - return name.length > 0 ? name : undefined; - } catch { - return undefined; - } -} - -function defaultOsUser(env: LegacyParseEnv): string { - return libpqEnv(env, "PGUSER") ?? osAccountUsername() ?? "postgres"; +function defaultOsUser( + env: LegacyParseEnv, + runtime: LegacyDbConfigParseRuntime | undefined, +): string { + return libpqEnv(env, "PGUSER") ?? runtime?.osUser ?? "postgres"; } /** diff --git a/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts index dd706b3326..cbd2fec7f6 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.parse.unit.test.ts @@ -1,13 +1,18 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir, userInfo } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, ManagedRuntime, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import { afterEach, beforeEach } from "vitest"; import { legacyPoolerConfigFromConnectionString, - parseLegacyConnectionString, + parseLegacyConnectionString as parseLegacyConnectionStringImpl, redactLegacyConnectionString, } from "./legacy-db-config.parse.ts"; +import type { LegacyDbConfigParseRuntime, LegacyParseEnv } from "./legacy-db-config.parse.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; + +import { userInfo } from "node:os"; // Mirrors the parser's default-user resolution: PGUSER (env) else the actual OS // account (os.userInfo().username, NOT $USER/$USERNAME) else "postgres". @@ -18,7 +23,59 @@ const osAccount = (() => { return undefined; } })(); -const osUser = process.env["PGUSER"] ?? osAccount ?? "postgres"; +const osUser = osAccount ?? "postgres"; + +const testPlatform = ManagedRuntime.make(BunServices.layer); +const testPath = testPlatform.runSync(Path.Path); +const tempRoot = useLegacyTempWorkdir("legacy-db-config-parse-"); +let fixtureCounter = 0; + +function join(...paths: ReadonlyArray<string>): string { + return testPath.join(...paths); +} + +const emptyEnv: LegacyParseEnv = () => undefined; + +function parseLegacyConnectionString( + value: string, + env: LegacyParseEnv = emptyEnv, + runtime: Partial<LegacyDbConfigParseRuntime> = {}, +) { + return parseLegacyConnectionStringImpl(value, env, { + osUser, + join, + files: new Map(), + serviceFiles: new Map(), + ...runtime, + }); +} + +const createFixture = ( + prefix: string, + files: ReadonlyArray<Readonly<{ readonly path: string; readonly content: string }>>, +): Effect.Effect<string, PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(tempRoot.current, `${prefix}${fixtureCounter++}`); + yield* fs.makeDirectory(dir, { recursive: true }); + for (const file of files) { + const absolute = path.join(dir, file.path); + yield* fs.makeDirectory(path.dirname(absolute), { recursive: true }); + yield* fs.writeFileString(absolute, file.content); + } + return dir; + }); + +const removeFixture = (dir: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(dir, { recursive: true, force: true }); + }); + +const runFixture = <A, E>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, +): Promise<A> => Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); describe("parseLegacyConnectionString (URL form)", () => { it("parses host/port/user/password/database and percent-decodes userinfo", () => { @@ -62,39 +119,26 @@ describe("parseLegacyConnectionString (URL form)", () => { }); it("fills omitted URL fields from PG* env vars, with explicit fields winning", () => { - const prev = { - PGPASSWORD: process.env["PGPASSWORD"], - PGPORT: process.env["PGPORT"], - PGDATABASE: process.env["PGDATABASE"], - }; - process.env["PGPASSWORD"] = "env-secret"; - process.env["PGPORT"] = "6543"; - process.env["PGDATABASE"] = "envdb"; - try { - // Password/port/database omitted from the URL → taken from PG* env. - expect(parseLegacyConnectionString("postgresql://alice@db.example.com")).toEqual({ - host: "db.example.com", - port: 6543, - user: "alice", - password: "env-secret", - database: "envdb", - }); - // Explicit URL fields override the env defaults (connStringSettings win). - expect( - parseLegacyConnectionString("postgresql://alice:pw@db.example.com:5555/appdb"), - ).toEqual({ - host: "db.example.com", - port: 5555, - user: "alice", - password: "pw", - database: "appdb", - }); - } finally { - for (const [k, v] of Object.entries(prev)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - } + const env: LegacyParseEnv = (name) => + ({ PGPASSWORD: "env-secret", PGPORT: "6543", PGDATABASE: "envdb" })[name]; + // Password/port/database omitted from the URL → taken from PG* env. + expect(parseLegacyConnectionString("postgresql://alice@db.example.com", env)).toEqual({ + host: "db.example.com", + port: 6543, + user: "alice", + password: "env-secret", + database: "envdb", + }); + // Explicit URL fields override the env defaults (connStringSettings win). + expect( + parseLegacyConnectionString("postgresql://alice:pw@db.example.com:5555/appdb", env), + ).toEqual({ + host: "db.example.com", + port: 5555, + user: "alice", + password: "pw", + database: "appdb", + }); }); it("honors libpq query params (host/dbname) over the structural URL (pgconn parity)", () => { @@ -176,18 +220,26 @@ describe("parseLegacyConnectionString (URL form)", () => { expect(parsed?.runtimeParams?.application_name).toBe("from-url"); }); - it("merges a pg_service.conf runtime setting (search_path) into runtimeParams", () => { - const dir = mkdtempSync(join(tmpdir(), "pgservice-")); - const file = join(dir, "pg_service.conf"); - writeFileSync(file, "[tenant]\nhost=svc.example.com\nsearch_path=tenant_schema\n"); - const parsed = parseLegacyConnectionString( - `postgres:///db?service=tenant&servicefile=${file}`, - () => undefined, - ); - expect(parsed?.host).toBe("svc.example.com"); - expect(parsed?.runtimeParams?.search_path).toBe("tenant_schema"); - rmSync(dir, { recursive: true, force: true }); - }); + it.effect("merges a pg_service.conf runtime setting (search_path) into runtimeParams", () => + Effect.gen(function* () { + const dir = yield* createFixture("pgservice-", [ + { + path: "pg_service.conf", + content: "[tenant]\nhost=svc.example.com\nsearch_path=tenant_schema\n", + }, + ]); + const file = join(dir, "pg_service.conf"); + const serviceContent = "[tenant]\nhost=svc.example.com\nsearch_path=tenant_schema\n"; + const parsed = parseLegacyConnectionString( + `postgres:///db?service=tenant&servicefile=${file}`, + () => undefined, + { serviceFiles: new Map([[file, serviceContent]]) }, + ); + expect(parsed?.host).toBe("svc.example.com"); + expect(parsed?.runtimeParams?.search_path).toBe("tenant_schema"); + yield* removeFixture(dir); + }).pipe(Effect.provide(BunServices.layer)), + ); it("carries client sslcert/sslkey (and sslpassword) from a --db-url", () => { const parsed = parseLegacyConnectionString( @@ -230,16 +282,10 @@ describe("parseLegacyConnectionString (URL form)", () => { }); it("rejects an invalid PGPORT fallback instead of defaulting to 5432", () => { - const prev = process.env["PGPORT"]; - process.env["PGPORT"] = "abc"; - try { - // No port in the URL or DSN → falls back to PGPORT, which is invalid → reject. - expect(parseLegacyConnectionString("postgresql://host/db")).toBeUndefined(); - expect(parseLegacyConnectionString("host=pg.example.com user=admin")).toBeUndefined(); - } finally { - if (prev === undefined) delete process.env["PGPORT"]; - else process.env["PGPORT"] = prev; - } + const env: LegacyParseEnv = (name) => (name === "PGPORT" ? "abc" : undefined); + // No port in the URL or DSN → falls back to PGPORT, which is invalid → reject. + expect(parseLegacyConnectionString("postgresql://host/db", env)).toBeUndefined(); + expect(parseLegacyConnectionString("host=pg.example.com user=admin", env)).toBeUndefined(); }); it("rejects an invalid sslmode value (pgconn 'sslmode is invalid')", () => { @@ -257,18 +303,14 @@ describe("parseLegacyConnectionString (URL form)", () => { }); it("fills sslmode from PGSSLMODE when the URL omits it (pgconn env default)", () => { - const prev = process.env["PGSSLMODE"]; - process.env["PGSSLMODE"] = "verify-full"; - try { - expect(parseLegacyConnectionString("postgres://u:pw@h:5432/db")?.sslmode).toBe("verify-full"); - // An explicit query sslmode still wins over PGSSLMODE. - expect( - parseLegacyConnectionString("postgres://u:pw@h:5432/db?sslmode=disable")?.sslmode, - ).toBe("disable"); - } finally { - if (prev === undefined) delete process.env["PGSSLMODE"]; - else process.env["PGSSLMODE"] = prev; - } + const env: LegacyParseEnv = (name) => (name === "PGSSLMODE" ? "verify-full" : undefined); + expect(parseLegacyConnectionString("postgres://u:pw@h:5432/db", env)?.sslmode).toBe( + "verify-full", + ); + // An explicit query sslmode still wins over PGSSLMODE. + expect( + parseLegacyConnectionString("postgres://u:pw@h:5432/db?sslmode=disable", env)?.sslmode, + ).toBe("disable"); }); it("rejects a non-Postgres URL scheme instead of connecting to a bogus host", () => { @@ -319,76 +361,50 @@ describe("parseLegacyConnectionString (libpq keyword/value DSN)", () => { }); it("prefers PGUSER over the OS account for the default user (pgconn env precedence)", () => { - const prev = process.env["PGUSER"]; - process.env["PGUSER"] = "pg_role"; - try { - // No user= keyword: PGUSER wins over USER/USERNAME, and the database - // defaults to that resolved user — matching pgconn's - // mergeSettings(defaultSettings, envSettings, connStringSettings) order. - expect(parseLegacyConnectionString("host=pg.example.com")).toEqual({ - host: "pg.example.com", - port: 5432, - user: "pg_role", - database: "pg_role", - password: "", - }); - // An explicit user= still wins over PGUSER (connStringSettings override env). - expect(parseLegacyConnectionString("host=h user=explicit")?.user).toBe("explicit"); - // The URL form without userinfo also honors PGUSER. - expect(parseLegacyConnectionString("postgresql://localhost/mydb")?.user).toBe("pg_role"); - } finally { - if (prev === undefined) delete process.env["PGUSER"]; - else process.env["PGUSER"] = prev; - } + const env: LegacyParseEnv = (name) => (name === "PGUSER" ? "pg_role" : undefined); + // No user= keyword: PGUSER wins over USER/USERNAME, and the database + // defaults to that resolved user — matching pgconn's + // mergeSettings(defaultSettings, envSettings, connStringSettings) order. + expect(parseLegacyConnectionString("host=pg.example.com", env)).toEqual({ + host: "pg.example.com", + port: 5432, + user: "pg_role", + database: "pg_role", + password: "", + }); + // An explicit user= still wins over PGUSER (connStringSettings override env). + expect(parseLegacyConnectionString("host=h user=explicit", env)?.user).toBe("explicit"); + // The URL form without userinfo also honors PGUSER. + expect(parseLegacyConnectionString("postgresql://localhost/mydb", env)?.user).toBe("pg_role"); }); it("fills omitted DSN fields from PG* env vars (pgconn env defaults)", () => { - const prev = { - PGHOST: process.env["PGHOST"], - PGPORT: process.env["PGPORT"], - PGPASSWORD: process.env["PGPASSWORD"], - PGDATABASE: process.env["PGDATABASE"], - }; - process.env["PGHOST"] = "pg.env.com"; - process.env["PGPORT"] = "6543"; - process.env["PGPASSWORD"] = "env-secret"; - process.env["PGDATABASE"] = "envdb"; - try { - expect(parseLegacyConnectionString("user=admin")).toEqual({ - host: "pg.env.com", - port: 6543, - user: "admin", - password: "env-secret", - database: "envdb", - }); - // Explicit keywords override the env defaults. - expect( - parseLegacyConnectionString("host=h port=1234 user=admin dbname=db password=pw"), - ).toEqual({ - host: "h", - port: 1234, - user: "admin", - password: "pw", - database: "db", - }); - } finally { - for (const [k, v] of Object.entries(prev)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - } + const env: LegacyParseEnv = (name) => + ({ PGHOST: "pg.env.com", PGPORT: "6543", PGPASSWORD: "env-secret", PGDATABASE: "envdb" })[ + name + ]; + expect(parseLegacyConnectionString("user=admin", env)).toEqual({ + host: "pg.env.com", + port: 6543, + user: "admin", + password: "env-secret", + database: "envdb", + }); + // Explicit keywords override the env defaults. + expect( + parseLegacyConnectionString("host=h port=1234 user=admin dbname=db password=pw", env), + ).toEqual({ + host: "h", + port: 1234, + user: "admin", + password: "pw", + database: "db", + }); }); it("falls back to a libpq default host when host and PGHOST are absent", () => { - const prev = process.env["PGHOST"]; - delete process.env["PGHOST"]; - try { - // No host= and no PGHOST → libpq default (a unix-socket dir or "localhost"). - expect(parseLegacyConnectionString("user=admin")?.host).toMatch(/^(\/|localhost)/); - } finally { - if (prev === undefined) delete process.env["PGHOST"]; - else process.env["PGHOST"] = prev; - } + // No host= and no PGHOST → libpq default (a unix-socket dir or "localhost"). + expect(parseLegacyConnectionString("user=admin")?.host).toMatch(/^(\/|localhost)/); }); it("returns undefined when a keyword has no '=' value", () => { @@ -407,59 +423,79 @@ describe("empty-password precedence (pgconn parity)", () => { // PGPASSFILE at a temp file we control and set PGPASSWORD to prove which one wins. let tmp: string; let pgpassPath: string; - const prev: Record<string, string | undefined> = {}; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pgpass-")); - pgpassPath = join(tmp, ".pgpass"); - for (const k of ["PGPASSWORD", "PGPASSFILE", "PGPORT", "PGDATABASE", "PGHOST"]) { - prev[k] = process.env[k]; - delete process.env[k]; - } - process.env["PGPASSWORD"] = "env-secret"; - process.env["PGPASSFILE"] = pgpassPath; - // host db.example.com, port 6543, db appdb, user alice. - writeFileSync(pgpassPath, "db.example.com:6543:appdb:alice:pgpass-secret\n"); - }); - - afterEach(() => { - for (const [k, v] of Object.entries(prev)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - rmSync(tmp, { recursive: true, force: true }); - }); + let pgpassRuntime: Partial<LegacyDbConfigParseRuntime>; + let pgpassEnv: LegacyParseEnv; + + beforeEach(() => + runFixture( + Effect.gen(function* () { + tmp = yield* createFixture("pgpass-", [ + { + path: ".pgpass", + content: "db.example.com:6543:appdb:alice:pgpass-secret\n", + }, + ]); + pgpassPath = join(tmp, ".pgpass"); + pgpassRuntime = { + files: new Map([[pgpassPath, "db.example.com:6543:appdb:alice:pgpass-secret\n"]]), + }; + pgpassEnv = (name) => + name === "PGPASSWORD" ? "env-secret" : name === "PGPASSFILE" ? pgpassPath : undefined; + }), + ), + ); + + afterEach(() => runFixture(removeFixture(tmp))); it("uses PGPASSWORD when the URL has no password component at all (user@host)", () => { expect( - parseLegacyConnectionString("postgres://alice@db.example.com:6543/appdb")?.password, + parseLegacyConnectionString( + "postgres://alice@db.example.com:6543/appdb", + pgpassEnv, + pgpassRuntime, + )?.password, ).toBe("env-secret"); }); it("an explicit empty URL userinfo password (user:@host) suppresses PGPASSWORD → .pgpass", () => { expect( - parseLegacyConnectionString("postgres://alice:@db.example.com:6543/appdb")?.password, + parseLegacyConnectionString( + "postgres://alice:@db.example.com:6543/appdb", + pgpassEnv, + pgpassRuntime, + )?.password, ).toBe("pgpass-secret"); }); it("an explicit empty ?password= suppresses PGPASSWORD → .pgpass", () => { expect( - parseLegacyConnectionString("postgres://alice@db.example.com:6543/appdb?password=")?.password, + parseLegacyConnectionString( + "postgres://alice@db.example.com:6543/appdb?password=", + pgpassEnv, + pgpassRuntime, + )?.password, ).toBe("pgpass-secret"); }); it("an explicit empty DSN password= suppresses PGPASSWORD → .pgpass", () => { expect( - parseLegacyConnectionString("host=db.example.com port=6543 dbname=appdb user=alice password=") - ?.password, + parseLegacyConnectionString( + "host=db.example.com port=6543 dbname=appdb user=alice password=", + pgpassEnv, + pgpassRuntime, + )?.password, ).toBe("pgpass-secret"); }); it("falls through to an empty password when neither PGPASSWORD nor .pgpass match", () => { - delete process.env["PGPASSWORD"]; + const env: LegacyParseEnv = (name) => (name === "PGPASSFILE" ? pgpassPath : undefined); // No matching .pgpass line for this host → empty. expect( - parseLegacyConnectionString("postgres://alice:@other.example.com:6543/appdb")?.password, + parseLegacyConnectionString( + "postgres://alice:@other.example.com:6543/appdb", + env, + pgpassRuntime, + )?.password, ).toBe(""); }); }); @@ -544,33 +580,44 @@ describe("passfile= DSN setting (pgconn parity)", () => { // different one to prove the connection-string setting wins. let tmp: string; let customPath: string; - const prev: Record<string, string | undefined> = {}; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "passfile-")); - customPath = join(tmp, "custom-pgpass"); - const envPath = join(tmp, "env-pgpass"); - for (const k of ["PGPASSWORD", "PGPASSFILE", "PGPORT", "PGDATABASE", "PGHOST"]) { - prev[k] = process.env[k]; - delete process.env[k]; - } - process.env["PGPASSFILE"] = envPath; - writeFileSync(envPath, "db.example.com:6543:appdb:alice:env-file-secret\n"); - writeFileSync(customPath, "db.example.com:6543:appdb:alice:custom-file-secret\n"); - }); - - afterEach(() => { - for (const [k, v] of Object.entries(prev)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - rmSync(tmp, { recursive: true, force: true }); - }); + let envPath: string; + let passfileRuntime: Partial<LegacyDbConfigParseRuntime>; + let passfileEnv: LegacyParseEnv; + + beforeEach(() => + runFixture( + Effect.gen(function* () { + tmp = yield* createFixture("passfile-", [ + { + path: "env-pgpass", + content: "db.example.com:6543:appdb:alice:env-file-secret\n", + }, + { + path: "custom-pgpass", + content: "db.example.com:6543:appdb:alice:custom-file-secret\n", + }, + ]); + customPath = join(tmp, "custom-pgpass"); + envPath = join(tmp, "env-pgpass"); + passfileRuntime = { + files: new Map([ + [envPath, "db.example.com:6543:appdb:alice:env-file-secret\n"], + [customPath, "db.example.com:6543:appdb:alice:custom-file-secret\n"], + ]), + }; + passfileEnv = (name) => (name === "PGPASSFILE" ? envPath : undefined); + }), + ), + ); + + afterEach(() => runFixture(removeFixture(tmp))); it("resolves the password from a ?passfile= URL setting over PGPASSFILE", () => { expect( parseLegacyConnectionString( `postgres://alice@db.example.com:6543/appdb?passfile=${customPath}`, + passfileEnv, + passfileRuntime, )?.password, ).toBe("custom-file-secret"); }); @@ -579,13 +626,19 @@ describe("passfile= DSN setting (pgconn parity)", () => { expect( parseLegacyConnectionString( `host=db.example.com port=6543 dbname=appdb user=alice passfile=${customPath}`, + passfileEnv, + passfileRuntime, )?.password, ).toBe("custom-file-secret"); }); it("falls back to PGPASSFILE when no passfile= setting is present", () => { expect( - parseLegacyConnectionString("postgres://alice@db.example.com:6543/appdb")?.password, + parseLegacyConnectionString( + "postgres://alice@db.example.com:6543/appdb", + passfileEnv, + passfileRuntime, + )?.password, ).toBe("env-file-secret"); }); @@ -593,11 +646,18 @@ describe("passfile= DSN setting (pgconn parity)", () => { // pgconn: present-empty passfile overrides PGPASSFILE, then ReadPassfile("") fails // → no .pgpass lookup → empty password (not the env-file credential). expect( - parseLegacyConnectionString("postgres://alice@db.example.com:6543/appdb?passfile=")?.password, + parseLegacyConnectionString( + "postgres://alice@db.example.com:6543/appdb?passfile=", + passfileEnv, + passfileRuntime, + )?.password, ).toBe(""); expect( - parseLegacyConnectionString("host=db.example.com port=6543 dbname=appdb user=alice passfile=") - ?.password, + parseLegacyConnectionString( + "host=db.example.com port=6543 dbname=appdb user=alice passfile=", + passfileEnv, + passfileRuntime, + )?.password, ).toBe(""); }); }); @@ -647,23 +707,40 @@ describe("pgservice resolution (pgconn parity)", () => { // is a hard parse error. let tmp: string; let servicefile: string; - - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pgservice-parse-")); - servicefile = join(tmp, "pg_service.conf"); - writeFileSync( - servicefile, - "[prod]\nhost=db.example.com\nport=6543\nuser=alice\npassword=svc-secret\ndbname=appdb\nsslmode=require\n", - ); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); + let serviceRuntime: Partial<LegacyDbConfigParseRuntime>; + + beforeEach(() => + runFixture( + Effect.gen(function* () { + tmp = yield* createFixture("pgservice-parse-", [ + { + path: "pg_service.conf", + content: + "[prod]\nhost=db.example.com\nport=6543\nuser=alice\npassword=svc-secret\ndbname=appdb\nsslmode=require\n", + }, + ]); + servicefile = join(tmp, "pg_service.conf"); + serviceRuntime = { + serviceFiles: new Map([ + [ + servicefile, + "[prod]\nhost=db.example.com\nport=6543\nuser=alice\npassword=svc-secret\ndbname=appdb\nsslmode=require\n", + ], + ]), + }; + }), + ), + ); + + afterEach(() => runFixture(removeFixture(tmp))); it("resolves host/port/user/password/database/sslmode from the named service", () => { expect( - parseLegacyConnectionString(`postgresql:///?service=prod&servicefile=${servicefile}`), + parseLegacyConnectionString( + `postgresql:///?service=prod&servicefile=${servicefile}`, + emptyEnv, + serviceRuntime, + ), ).toEqual({ host: "db.example.com", port: 6543, @@ -675,7 +752,13 @@ describe("pgservice resolution (pgconn parity)", () => { }); it("resolves a service from the keyword/value DSN form too", () => { - expect(parseLegacyConnectionString(`service=prod servicefile=${servicefile}`)).toEqual({ + expect( + parseLegacyConnectionString( + `service=prod servicefile=${servicefile}`, + emptyEnv, + serviceRuntime, + ), + ).toEqual({ host: "db.example.com", port: 6543, user: "alice", @@ -689,6 +772,8 @@ describe("pgservice resolution (pgconn parity)", () => { expect( parseLegacyConnectionString( `postgresql://bob:pw@real.example.com:5555/realdb?service=prod&servicefile=${servicefile}`, + emptyEnv, + serviceRuntime, ), ).toEqual({ host: "real.example.com", @@ -703,18 +788,28 @@ describe("pgservice resolution (pgconn parity)", () => { it("resolves the service from the injected env (PGSERVICE/PGSERVICEFILE)", () => { const env = (name: string): string | undefined => ({ PGSERVICE: "prod", PGSERVICEFILE: servicefile })[name]; - expect(parseLegacyConnectionString("postgresql:///", env)?.host).toBe("db.example.com"); + expect(parseLegacyConnectionString("postgresql:///", env, serviceRuntime)?.host).toBe( + "db.example.com", + ); }); it("fails to parse (undefined) when the service is unknown", () => { expect( - parseLegacyConnectionString(`postgresql:///?service=missing&servicefile=${servicefile}`), + parseLegacyConnectionString( + `postgresql:///?service=missing&servicefile=${servicefile}`, + emptyEnv, + serviceRuntime, + ), ).toBeUndefined(); }); it("fails to parse (undefined) when the service file does not exist", () => { expect( - parseLegacyConnectionString(`postgresql:///?service=prod&servicefile=${join(tmp, "nope")}`), + parseLegacyConnectionString( + `postgresql:///?service=prod&servicefile=${join(tmp, "nope")}`, + emptyEnv, + serviceRuntime, + ), ).toBeUndefined(); }); }); @@ -861,12 +956,14 @@ describe("pgconn parse refinements", () => { describe("database= alias and empty service values (pgconn parity)", () => { let tmp: string; - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "svc-empty-")); - }); - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); + beforeEach(() => + runFixture( + Effect.gen(function* () { + tmp = yield* createFixture("svc-empty-", []); + }), + ), + ); + afterEach(() => runFixture(removeFixture(tmp))); it("honors a `database=` query key as an alias for dbname", () => { expect(parseLegacyConnectionString("postgres://host/postgres?database=prod")).toMatchObject({ @@ -889,37 +986,59 @@ describe("database= alias and empty service values (pgconn parity)", () => { ).toMatchObject({ database: "template1" }); }); - it("an empty service password= suppresses PGPASSWORD (falls through to .pgpass)", () => { - const sf = join(tmp, "svc.conf"); - writeFileSync(sf, "[s]\nhost=h\nport=5432\nuser=u\ndbname=d\npassword=\n"); - const env = (name: string): string | undefined => - name === "PGPASSWORD" - ? "env-secret" - : name === "PGPASSFILE" - ? join(tmp, "no-pgpass") - : undefined; - // Empty service password overrides PGPASSWORD; no .pgpass match → "". - expect( - parseLegacyConnectionString(`postgres:///?service=s&servicefile=${sf}`, env)?.password, - ).toBe(""); - }); - - it("an empty service connect_timeout= is a parse error", () => { - const sf = join(tmp, "svc.conf"); - writeFileSync(sf, "[s]\nhost=h\nport=5432\nuser=u\nconnect_timeout=\n"); - expect(parseLegacyConnectionString(`postgres:///?service=s&servicefile=${sf}`)).toBeUndefined(); - }); - - it("still uses a non-empty service value normally", () => { - const sf = join(tmp, "svc.conf"); - writeFileSync(sf, "[s]\nhost=svc.example.com\nport=6543\nuser=alice\ndbname=appdb\n"); - expect(parseLegacyConnectionString(`postgres:///?service=s&servicefile=${sf}`)).toMatchObject({ - host: "svc.example.com", - port: 6543, - user: "alice", - database: "appdb", - }); - }); + it.effect("an empty service password= suppresses PGPASSWORD (falls through to .pgpass)", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sf = join(tmp, "svc.conf"); + const serviceContent = "[s]\nhost=h\nport=5432\nuser=u\ndbname=d\npassword=\n"; + yield* fs.writeFileString(sf, serviceContent); + const env = (name: string): string | undefined => + name === "PGPASSWORD" + ? "env-secret" + : name === "PGPASSFILE" + ? join(tmp, "no-pgpass") + : undefined; + // Empty service password overrides PGPASSWORD; no .pgpass match → "". + expect( + parseLegacyConnectionString(`postgres:///?service=s&servicefile=${sf}`, env, { + serviceFiles: new Map([[sf, serviceContent]]), + })?.password, + ).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("an empty service connect_timeout= is a parse error", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sf = join(tmp, "svc.conf"); + const serviceContent = "[s]\nhost=h\nport=5432\nuser=u\nconnect_timeout=\n"; + yield* fs.writeFileString(sf, serviceContent); + expect( + parseLegacyConnectionString(`postgres:///?service=s&servicefile=${sf}`, emptyEnv, { + serviceFiles: new Map([[sf, serviceContent]]), + }), + ).toBeUndefined(); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("still uses a non-empty service value normally", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sf = join(tmp, "svc.conf"); + const serviceContent = "[s]\nhost=svc.example.com\nport=6543\nuser=alice\ndbname=appdb\n"; + yield* fs.writeFileString(sf, serviceContent); + expect( + parseLegacyConnectionString(`postgres:///?service=s&servicefile=${sf}`, emptyEnv, { + serviceFiles: new Map([[sf, serviceContent]]), + }), + ).toMatchObject({ + host: "svc.example.com", + port: 6543, + user: "alice", + database: "appdb", + }); + }).pipe(Effect.provide(BunServices.layer)), + ); }); describe("more pgconn parse refinements", () => { diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index cee3825b7d..79549caced 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,4 +1,4 @@ -import { Effect, type FileSystem, Option, type Path } from "effect"; +import { Effect, Schema, type FileSystem, Option, type Path, Predicate } from "effect"; import * as SmolToml from "smol-toml"; import { LEGACY_PROJECT_REF_PATTERN, @@ -25,6 +25,7 @@ import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { legacyStrToArr } from "./legacy-local-config-values.ts"; import { ramInBytes } from "./legacy-size-units.ts"; +import { LegacyViperEnv, legacyViperEnvEntries } from "../../shared/legacy/legacy-viper-env.ts"; import { legacyCollectDotenvPrivateKeys, legacyDecryptSecret, @@ -999,14 +1000,32 @@ const LEGACY_PROCESS_ENV_APPLY_KEYS = [ "PGDELTA_NPM_REGISTRY", ] as const; +const LEGACY_ENV_LOOKUP_NAMES = [ + "SUPABASE_ENV", + "DOTENV_PRIVATE_KEY", + ...LEGACY_PROCESS_ENV_APPLY_KEYS, + ...LEGACY_ENV_OVERRIDABLE_KEYS.map((key) => `SUPABASE_${key.replaceAll(".", "_").toUpperCase()}`), +]; + +function legacyCollectEnvReferences(content: string | undefined): ReadonlySet<string> { + const names = new Set<string>(LEGACY_ENV_LOOKUP_NAMES); + if (content === undefined) return names; + const referencePattern = /env\(([A-Za-z_][A-Za-z0-9_]*)\)/gu; + for (const match of content.matchAll(referencePattern)) { + const name = match[1]; + if (name !== undefined) names.add(name); + } + return names; +} + /** * Load the project's nested `.env` files into a lookup map. **Pure**: it reads the - * files and returns the merged map, with no `process.env` side effect — so the + * files and returns the merged map, with no ambient environment side effect — so the * `SUPABASE_YES` / `SUPABASE_DB_PASSWORD` readers that call it * (`legacyResolveYesWithProjectEnv`, `resolveDbPassword`) never mutate the global * environment. Commands that need an allowlisted key visible to a synchronous - * `process.env` reader (`db dump` / `db pull` → `legacyGetRegistryImageUrl`) opt - * into {@link legacyApplyProjectEnv} around the container work instead. + * consumer (`db dump` / `db pull` → `legacyGetRegistryImageUrl`) pass the returned + * values explicitly instead. * * Partially mirrors `loadNestedEnv` + `loadDefaultEnv`. * Go walks from the `supabase/` directory up to @@ -1018,52 +1037,74 @@ const LEGACY_PROCESS_ENV_APPLY_KEYS = [ * read — aborts: `loadEnvIfExists` swallows only `os.ErrNotExist` and returns * every other error. The path is named without leaking file contents (CWE-209-safe). */ -export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( +export const legacyLoadProjectEnv = ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, -) { - const env = process.env["SUPABASE_ENV"] || DEFAULT_SUPABASE_ENV; - const filenames = [`.env.${env}.local`]; - if (env !== "test") filenames.push(".env.local"); - filenames.push(`.env.${env}`, ".env"); - // Go walks `supabase/` first, then the repo root; first writer wins. - const dirs = [path.join(workdir, "supabase"), workdir]; - const loaded: Record<string, string> = {}; - for (const dir of dirs) { - for (const name of filenames) { - // Go's loadEnvIfExists ignores only os.ErrNotExist; any other read error - // aborts rather than silently skipping the file (which would hide a broken - // env-backed config). Effect surfaces "not found" as a NotFound PlatformError. - const content = yield* fs.readFileString(path.join(dir, name)).pipe( - Effect.map(Option.some<string>), - Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" - ? Effect.succeed(Option.none<string>()) - : Effect.fail( +): Effect.Effect< + Readonly<Record<string, string>>, + LegacyDbConfigLoadError, + FileSystem.FileSystem | Path.Path | LegacyViperEnv +> => + Effect.gen(function* () { + const viper = yield* LegacyViperEnv; + const envValue = yield* viper.get("SUPABASE_ENV").pipe( + Effect.mapError( + () => + new LegacyDbConfigLoadError({ + message: "failed to read SUPABASE_ENV from the environment", + }), + ), + ); + const env = Option.getOrElse(envValue, () => DEFAULT_SUPABASE_ENV) || DEFAULT_SUPABASE_ENV; + const filenames = [`.env.${env}.local`]; + if (env !== "test") filenames.push(".env.local"); + filenames.push(`.env.${env}`, ".env"); + // Go walks `supabase/` first, then the repo root; first writer wins. + const dirs = [path.join(workdir, "supabase"), workdir]; + const loaded: Record<string, string> = {}; + for (const dir of dirs) { + for (const name of filenames) { + // Go's loadEnvIfExists ignores only os.ErrNotExist; any other read error + // aborts rather than silently skipping the file (which would hide a broken + // env-backed config). Effect surfaces "not found" as a NotFound PlatformError. + const content = yield* fs.readFileString(path.join(dir, name)).pipe( + Effect.map(Option.some<string>), + Effect.catchTag("PlatformError", (error) => + Predicate.isTagged(error.reason, "NotFound") + ? Effect.succeed(Option.none<string>()) + : Effect.fail( + new LegacyDbConfigLoadError({ + message: `failed to read environment file: ${name}`, + }), + ), + ), + ); + if (Option.isNone(content)) continue; + const parsed = yield* Effect.try({ + try: () => parseDotEnv(content.value), + catch: () => + new LegacyDbConfigLoadError({ + message: `failed to parse environment file: ${name}`, + }), + }); + for (const [key, value] of Object.entries(parsed)) { + // godotenv.Load never overrides: the shell env and earlier files win. Read shell + // presence through the injected viper environment so empty shell values still win. + const shellValue = yield* viper.get(key).pipe( + Effect.mapError( + () => new LegacyDbConfigLoadError({ - message: `failed to read environment file: ${name}`, + message: `failed to read environment variable: ${key}`, }), - ), - ), - ); - if (Option.isNone(content)) continue; - let parsed: Record<string, string>; - try { - parsed = parseDotEnv(content.value); - } catch { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse environment file: ${name}` }), - ); - } - for (const [key, value] of Object.entries(parsed)) { - // godotenv.Load never overrides: the shell env and earlier files win. - if (process.env[key] === undefined && loaded[key] === undefined) loaded[key] = value; + ), + ); + if (Option.isNone(shellValue) && loaded[key] === undefined) loaded[key] = value; + } } } - } - return loaded; -}); + return loaded; + }); /** * Apply the allowlisted project-`.env` keys (see {@link LEGACY_PROCESS_ENV_APPLY_KEYS}) @@ -1084,26 +1125,27 @@ export const legacyLoadProjectEnv = Effect.fnUntraced(function* ( export const legacyApplyProjectEnv = ( loaded: Readonly<Record<string, string>>, keys: ReadonlyArray<string> = LEGACY_PROCESS_ENV_APPLY_KEYS, -) => - Effect.forEach( - keys, - (key) => { - const value = loaded[key]; - if (value === undefined || process.env[key] !== undefined) { - return Effect.void; - } - return Effect.acquireRelease( - Effect.sync(() => { - process.env[key] = value; - }), - () => - Effect.sync(() => { - delete process.env[key]; - }), +): Effect.Effect<Readonly<Record<string, string>>, LegacyDbConfigLoadError, LegacyViperEnv> => + Effect.gen(function* () { + const viper = yield* LegacyViperEnv; + const resolved: Record<string, string> = {}; + for (const key of keys) { + const shellValue = yield* viper.get(key).pipe( + Effect.mapError( + () => + new LegacyDbConfigLoadError({ + message: `failed to read environment variable: ${key}`, + }), + ), ); - }, - { discard: true }, - ); + if (Option.isSome(shellValue)) { + resolved[key] = shellValue.value; + } else if (loaded[key] !== undefined) { + resolved[key] = loaded[key]; + } + } + return resolved; + }); function nonEmptyString(value: unknown): Option.Option<string> { return typeof value === "string" && value.length > 0 ? Option.some(value) : Option.none(); @@ -1152,17 +1194,17 @@ const resolveBoolOrFail = Effect.fnUntraced(function* ( if (envValue !== undefined) { const parsed = legacyParseGoBool(legacyExpandEnv(envValue, lookup)); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid ${field}.`, + }); } return parsed; } const resolved = resolveBool(value, fallback, lookup); if (resolved === "invalid") { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid ${field}.`, + }); } return resolved; }); @@ -1184,9 +1226,9 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( if (envValue !== undefined) { const parsed = legacyParseGoBool(legacyExpandEnv(envValue, lookup)); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid ${field}.`, + }); } return Option.some(parsed); } @@ -1196,18 +1238,18 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( if (typeof value === "string") { const parsed = legacyParseGoBool(legacyExpandEnv(value, lookup)); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid ${field}.`, + }); } return Option.some(parsed); } // Absent → `None` (`*bool` stays nil). A present non-scalar value is a decode // failure, so reject it here rather than silently treating it as absent. if (value === undefined) return Option.none<boolean>(); - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid ${field}.`, + }); }); const LEGACY_VAULT_SECRET_PATH = ["db", "vault", "*"] as const; @@ -1391,7 +1433,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( : yield* fs.readFileString(configPath).pipe( Effect.map(Option.some<string>), Effect.catchTag("PlatformError", (error) => - error.reason._tag === "NotFound" + Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(Option.none<string>()) : Effect.fail( new LegacyDbConfigLoadError({ @@ -1405,12 +1447,56 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // here — before the remote-config validation/merge below — so remote and // top-level `project_id` env() forms are expanded before they are validated or // used to derive Docker IDs. + const viper = yield* LegacyViperEnv; const projectEnv = yield* legacyLoadProjectEnv(fs, path, workdir); - const lookup: EnvLookup = (name) => process.env[name] ?? projectEnv[name]; + const shellEnv: Record<string, string> = {}; + const envNames = legacyCollectEnvReferences( + Option.isSome(maybeContent) ? maybeContent.value : undefined, + ); + // AutomaticEnv values may themselves contain env(VAR) indirections (for example + // SUPABASE_DB_SEED_ENABLED=env(SEED_ON)). Resolve those referenced names through the + // injected environment service too; process.env enumeration is intentionally unavailable + // at this boundary, so discovery must remain explicit and deterministic. + const pendingEnvNames = [...envNames]; + const seenEnvNames = new Set<string>(); + while (pendingEnvNames.length > 0) { + const name = pendingEnvNames.shift(); + if (name === undefined || seenEnvNames.has(name)) continue; + seenEnvNames.add(name); + const value = yield* viper.get(name).pipe( + Effect.mapError( + () => + new LegacyDbConfigLoadError({ + message: `failed to read environment variable: ${name}`, + }), + ), + ); + if (Option.isSome(value)) { + shellEnv[name] = value.value; + const references = value.value.matchAll(/env\(([A-Za-z_][A-Za-z0-9_]*)\)/gu); + for (const match of references) { + const reference = match[1]; + if (reference !== undefined && !seenEnvNames.has(reference)) + pendingEnvNames.push(reference); + } + } + } + const dotenvPrivateShellEntries = yield* legacyViperEnvEntries("DOTENV_PRIVATE_KEY").pipe( + Effect.mapError( + () => + new LegacyDbConfigLoadError({ + message: "failed to read DOTENV_PRIVATE_KEY environment entries", + }), + ), + ); + for (const [name, value] of Object.entries(dotenvPrivateShellEntries)) { + shellEnv[name] = value; + } + const lookup: EnvLookup = (name) => shellEnv[name] ?? projectEnv[name]; // dotenvx private keys for decrypting `encrypted:` secrets, from the shell + project // env. Used by the global secret-decryptability assertion below and the `[db.vault]` // resolution. - const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...process.env }); + const dotenvPrivateKeys = legacyCollectDotenvPrivateKeys({ ...projectEnv, ...shellEnv }); let db: RawDoc | undefined; let pgDeltaRaw: RawDoc | undefined; @@ -1434,36 +1520,42 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // The matched `[remotes.<name>]` block name, echoed as the config-override line. let appliedRemote: string | undefined; if (Option.isSome(maybeContent)) { - let doc: RawDoc | undefined; - try { - doc = asRecord(SmolToml.parse(maybeContent.value)); - } catch (cause) { - return yield* Effect.fail( + const doc = yield* Effect.try({ + try: () => asRecord(SmolToml.parse(maybeContent.value)), + catch: (cause) => new LegacyDbConfigLoadError({ message: `failed to load config: ${cause instanceof Error ? cause.message : String(cause)}`, }), + }); + const remotes = asRecord(doc?.["remotes"]); + for (const name of remotes === undefined ? [] : Object.keys(remotes)) { + const envName = `SUPABASE_REMOTES_${name.toUpperCase()}_PROJECT_ID`; + const value = yield* viper.get(envName).pipe( + Effect.mapError( + () => + new LegacyDbConfigLoadError({ + message: `failed to read environment variable: ${envName}`, + }), + ), ); + if (Option.isSome(value)) shellEnv[envName] = value.value; } // Config load aborts when two `[remotes.*]` blocks share a `project_id`, // regardless of which command runs — check before merging. const duplicateRemote = findDuplicateRemoteProjectId(doc, lookup); if (duplicateRemote !== undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `duplicate project_id for [remotes.${duplicateRemote.name}] and [remotes.${duplicateRemote.other}]`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `duplicate project_id for [remotes.${duplicateRemote.name}] and [remotes.${duplicateRemote.other}]`, + }); } // Validation rejects any remote whose `project_id` is not a valid 20-char ref, on // every load, after the duplicate check. So a malformed remote fails even // local/direct commands before any DB connection. const invalidRemote = findInvalidRemoteProjectId(doc, lookup); if (invalidRemote !== undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Invalid config for remotes.${invalidRemote}.project_id. Must be like: abcdefghijklmnopqrst`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `Invalid config for remotes.${invalidRemote}.project_id. Must be like: abcdefghijklmnopqrst`, + }); } // Apply a matching `[remotes.<name>]` override: merge the block whose // `project_id` equals the resolved ref over the base. @@ -1505,7 +1597,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( includeVault: resolveVaultSecrets, }); if (secretError !== undefined) { - return yield* Effect.fail(new LegacyDbConfigLoadError({ message: secretError })); + return yield* new LegacyDbConfigLoadError({ message: secretError }); } } // `remoteOverrideKeys` has its final value from here on — see `legacyMakeRemoteWins`'s own doc @@ -1534,7 +1626,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // value/default. An empty env value is ignored, and the project `.env` files are // loaded into the environment first, so consult both. const envOverride = (name: string): string | undefined => { - const fromShell = process.env[name]; + const fromShell = shellEnv[name]; if (fromShell !== undefined && fromShell.length > 0) return fromShell; const fromFile = projectEnv[name]; return fromFile !== undefined && fromFile.length > 0 ? fromFile : undefined; @@ -1567,9 +1659,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // `db reset`) fails fast rather than dropping schemas on a config that should have // already failed validation. if (projectIdExplicitEmpty && Option.isNone(projectId)) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: "Missing required field in config: project_id" }), - ); + return yield* new LegacyDbConfigLoadError({ + message: "Missing required field in config: project_id", + }); } // A present-but-unmarshalable port aborts rather than defaulting, so `test db @@ -1587,19 +1679,17 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, ); if (port === undefined || shadowPort === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to load config: invalid ${port === undefined ? "db.port" : "db.shadow_port"} value`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to load config: invalid ${port === undefined ? "db.port" : "db.shadow_port"} value`, + }); } // Validation rejects an explicit `db.port = 0`; an absent port is defaulted before // validation, so only a present 0 fails. `resolvePort` accepts 0 as a syntactically // valid uint16, so the zero check lives here. No equivalent check for `shadow_port`. if (port === 0) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: "Missing required field in config: db.port" }), - ); + return yield* new LegacyDbConfigLoadError({ + message: "Missing required field in config: db.port", + }); } // `db.password` isn't part of the config schema (no `SUPABASE_DB_PASSWORD` env @@ -1626,11 +1716,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( typeof majorVersionRaw === "string" ? legacyExpandEnv(majorVersionRaw, lookup) : String(majorVersionRaw); - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Failed reading config: Invalid db.major_version: ${shown}.`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `Failed reading config: Invalid db.major_version: ${shown}.`, + }); } // Rejecting an unsupported major version ({13,14,15,17}) is // `legacyValidateResolvedConfig`'s `db.major_version` switch (called once, below) — an @@ -1681,11 +1769,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( typeof denoVersionRaw === "string" ? legacyExpandEnv(denoVersionRaw, lookup) : String(denoVersionRaw); - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `Failed reading config: Invalid edge_runtime.deno_version: ${shown}.`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `Failed reading config: Invalid edge_runtime.deno_version: ${shown}.`, + }); } // Rejecting a present-but-invalid deno_version (0 → missing-required, anything other // than 1/2 → invalid) is `legacyValidateResolvedConfig`'s `edgeRuntimeDenoVersion` @@ -1731,11 +1817,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const expandedWebhooksEnabledEnv = legacyExpandEnv(webhooksEnabledEnv, lookup); const parsed = legacyParseGoBool(expandedWebhooksEnabledEnv); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to parse config: invalid experimental.webhooks.enabled: ${expandedWebhooksEnabledEnv}.`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid experimental.webhooks.enabled: ${expandedWebhooksEnabledEnv}.`, + }); } webhooksEnabled = parsed; } else if (typeof webhooksEnabledRaw === "boolean") { @@ -1747,11 +1831,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } else if (typeof webhooksEnabledRaw === "string") { const parsed = legacyParseGoBool(legacyExpandEnv(webhooksEnabledRaw, lookup)); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to parse config: invalid experimental.webhooks.enabled: ${legacyExpandEnv(webhooksEnabledRaw, lookup)}.`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid experimental.webhooks.enabled: ${legacyExpandEnv(webhooksEnabledRaw, lookup)}.`, + }); } webhooksEnabled = parsed; } else { @@ -1780,11 +1862,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const expandedEnabledEnv = legacyExpandEnv(enabledEnv, lookup); const parsed = legacyParseGoBool(expandedEnabledEnv); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to parse config: invalid experimental.pgdelta.enabled: ${expandedEnabledEnv}.`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid experimental.pgdelta.enabled: ${expandedEnabledEnv}.`, + }); } enabled = parsed; } else if (typeof enabledRaw === "boolean") { @@ -1796,11 +1876,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } else if (typeof enabledRaw === "string") { const parsed = legacyParseGoBool(legacyExpandEnv(enabledRaw, lookup)); if (parsed === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ - message: `failed to parse config: invalid experimental.pgdelta.enabled: ${legacyExpandEnv(enabledRaw, lookup)}.`, - }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: invalid experimental.pgdelta.enabled: ${legacyExpandEnv(enabledRaw, lookup)}.`, + }); } enabled = parsed; } else { @@ -1860,15 +1938,13 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (typeof rawLimit !== "string" && typeof rawLimit !== "number") continue; const limitString = typeof rawLimit === "number" ? String(rawLimit) : legacyExpandEnv(rawLimit, lookup); - try { - ramInBytes(limitString); - } catch { - return yield* Effect.fail( + yield* Effect.try({ + try: () => ramInBytes(limitString), + catch: () => new LegacyDbConfigLoadError({ message: `failed to parse config: invalid storage.buckets.${bucketName}.file_size_limit.`, }), - ); - } + }); } } @@ -1943,24 +2019,21 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const signingKeysPath = str(authRawResolved, "signing_keys_path"); if (signingKeysPath.length > 0) { const keysJson = yield* fs - .readFileString(legacyResolveSigningKeysPath(workdir, signingKeysPath)) + .readFileString(legacyResolveSigningKeysPath(path, workdir, signingKeysPath)) .pipe( Effect.mapError( (cause) => new LegacyDbConfigLoadError({ message: legacySigningKeysReadErrorMessage(cause) }), ), ); - yield* Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(keysJson); - if (!Array.isArray(parsed)) { - throw new Error("signing keys must be a JSON array of JWKs"); - } - return parsed; - }, - catch: (cause) => - new LegacyDbConfigLoadError({ message: legacySigningKeysDecodeErrorMessage(cause) }), - }); + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Array(Schema.Unknown)))( + keysJson, + ).pipe( + Effect.mapError( + (cause) => + new LegacyDbConfigLoadError({ message: legacySigningKeysDecodeErrorMessage(cause) }), + ), + ); } // A6: passkey/webauthn when passkey enabled. @@ -2023,20 +2096,17 @@ const readDbTomlCore = Effect.fnUntraced(function* ( for (const name of Object.keys(templatesRaw)) { const tmpl = asRecord(templatesRaw[name]); if (tmpl === undefined) continue; - const contentPath = yield* Effect.try({ - try: () => - legacyResolveEmailTemplateContentPath({ - section: "template", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const contentPath = yield* legacyResolveEmailTemplateContentPath({ + path, + fileSystem: fs, + section: "template", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }).pipe( + Effect.mapError((cause) => new LegacyDbConfigLoadError({ message: cause.message })), + ); if (contentPath === undefined) continue; yield* fs.readFileString(contentPath).pipe( Effect.mapError( @@ -2058,20 +2128,17 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ) { continue; } - const contentPath = yield* Effect.try({ - try: () => - legacyResolveEmailTemplateContentPath({ - section: "notification", - name, - contentPath: str(tmpl, "content_path"), - contentPresent: tmpl["content"] !== undefined, - base: workdir, - }), - catch: (cause) => - new LegacyDbConfigLoadError({ - message: cause instanceof Error ? cause.message : String(cause), - }), - }); + const contentPath = yield* legacyResolveEmailTemplateContentPath({ + path, + fileSystem: fs, + section: "notification", + name, + contentPath: str(tmpl, "content_path"), + contentPresent: tmpl["content"] !== undefined, + base: workdir, + }).pipe( + Effect.mapError((cause) => new LegacyDbConfigLoadError({ message: cause.message })), + ); if (contentPath === undefined) continue; yield* fs.readFileString(contentPath).pipe( Effect.mapError( @@ -2642,9 +2709,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (legacyIsEncryptedSecret(value)) { const decrypted = legacyDecryptSecret(value, dotenvPrivateKeys); if (!decrypted.ok) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: `failed to parse config: ${decrypted.error}` }), - ); + return yield* new LegacyDbConfigLoadError({ + message: `failed to parse config: ${decrypted.error}`, + }); } vault.push({ name, value: decrypted.value, resolved: true }); continue; @@ -2673,9 +2740,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, ); if (apiSchemas === undefined) { - return yield* Effect.fail( - new LegacyDbConfigLoadError({ message: "failed to parse config: invalid api.schemas." }), - ); + return yield* new LegacyDbConfigLoadError({ + message: "failed to parse config: invalid api.schemas.", + }); } const values: LegacyDbTomlValues = { diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index d8c886fa6e..0cd29fb36c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -1,9 +1,18 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Option, Path } from "effect"; +import { + Config, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + ManagedRuntime, + Option, + Path, +} from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as Formatter from "effect/Formatter"; import { legacyApplyProjectEnv, @@ -13,9 +22,95 @@ import { legacyResolveDeclarativeDir, legacyResolveSeedSqlPath, } from "./legacy-db-config.toml-read.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; +import { + makeLegacyViperEnvLayer, + type LegacyViperEnv, +} from "../../shared/legacy/legacy-viper-env.ts"; + +const testPlatform = ManagedRuntime.make(BunServices.layer); +const testPath = testPlatform.runSync(Path.Path); +const tempRoot = useLegacyTempWorkdir("legacy-db-toml-"); +const testServices = (env: Readonly<Record<string, string>> = {}) => + Layer.mergeAll(BunServices.layer, makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env }))); + +function join(...paths: ReadonlyArray<string>): string { + return testPath.join(...paths); +} + +type FixtureOperation = Effect.Effect<void, PlatformError, FileSystem.FileSystem>; +const fixtureOperations = new Map<string, Array<FixtureOperation>>(); +let fixtureCounter = 0; + +function fixtureRoot(path: string): string | undefined { + return [...fixtureOperations.keys()] + .filter((root) => path === root || path.startsWith(`${root}/`)) + .sort((a, b) => b.length - a.length)[0]; +} + +function enqueueFixtureOperation(path: string, operation: FixtureOperation): void { + const root = fixtureRoot(path); + if (root === undefined) throw new Error(`fixture root not registered for ${path}`); + fixtureOperations.get(root)?.push(operation); +} + +function flushFixture(path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> { + const root = fixtureRoot(path); + if (root === undefined) return Effect.void; + const operations = fixtureOperations.get(root) ?? []; + fixtureOperations.delete(root); + return Effect.forEach(operations, (operation) => operation).pipe(Effect.asVoid); +} + +function mkdirSync(path: string, options?: { readonly recursive?: boolean }): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }), + ); +} + +function mkdtempSync(prefix: string): string { + const path = join(tempRoot.current, `${prefix}${fixtureCounter++}`); + fixtureOperations.set(path, []); + return path; +} + +function rmSync( + path: string, + options?: { readonly recursive?: boolean; readonly force?: boolean }, +): void { + const root = fixtureRoot(path); + if (root !== undefined) { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }), + ); + } +} + +function writeFileSync(path: string, data: string): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, data); + }), + ); +} + +const readEnvironment = (name: string, env: Readonly<Record<string, string>> = {}) => + Config.option(Config.string(name)).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ); function withConfig(content: string | undefined, poolerUrl?: string) { - const dir = mkdtempSync(join(tmpdir(), "legacy-db-toml-")); + const dir = mkdtempSync("legacy-db-toml-"); if (content !== undefined) { mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync(join(dir, "supabase", "config.toml"), content); @@ -27,37 +122,59 @@ function withConfig(content: string | undefined, poolerUrl?: string) { return dir; } -const read = (workdir: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyReadDbToml(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); - -const readRef = (workdir: string, ref: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyReadDbToml(fs, path, workdir, ref); - }).pipe(Effect.provide(BunServices.layer)); - -const loadEnv = (workdir: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyLoadProjectEnv(fs, path, workdir); - }).pipe(Effect.provide(BunServices.layer)); +const read = (workdir: string, env: Readonly<Record<string, string>> = {}) => + flushFixture(workdir).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, workdir); + }), + ), + Effect.provide(testServices(env)), + ); + +const readRef = (workdir: string, ref: string, env: Readonly<Record<string, string>> = {}) => + flushFixture(workdir).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyReadDbToml(fs, path, workdir, ref); + }), + ), + Effect.provide(testServices(env)), + ); + +const loadEnv = (workdir: string, env: Readonly<Record<string, string>> = {}) => + flushFixture(workdir).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyLoadProjectEnv(fs, path, workdir); + }), + ), + Effect.provide(testServices(env)), + ); describe("read (lenient) vs check (throws) split", () => { - const withServices = <A, E>( + type TomlServices = FileSystem.FileSystem | Path.Path | LegacyViperEnv; + const withServices = <A, E extends Error>( dir: string, - run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, E, never>, + run: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, E, TomlServices>, + env: Readonly<Record<string, string>> = {}, ) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* run(fs, path); - }).pipe(Effect.provide(BunServices.layer)); + flushFixture(dir).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* run(fs, path); + }), + ), + Effect.provide(testServices(env)), + ); it.effect("legacyCheckDbToml throws on an undecryptable secret", () => { const dir = withConfig('[db]\nroot_key = "encrypted:anything"\n'); @@ -67,7 +184,7 @@ describe("read (lenient) vs check (throws) split", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse config: missing private key", ); } @@ -161,49 +278,46 @@ describe("legacyReadDbToml", () => { "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; it.effect("decrypts an encrypted: [db.vault] secret when DOTENV_PRIVATE_KEY is set", () => { - const previous = process.env["DOTENV_PRIVATE_KEY"]; - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const dir = withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")); - return read(dir).pipe( + return read(dir, { DOTENV_PRIVATE_KEY: VAULT_PRIVATE_KEY }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.vault).toEqual([{ name: "my_secret", value: "value", resolved: true }]); }), ), - Effect.ensuring( + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + + it.effect("decrypts an encrypted: [db.vault] secret with a suffixed shell key", () => { + const dir = withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")); + return read(dir, { DOTENV_PRIVATE_KEY_PRODUCTION: VAULT_PRIVATE_KEY }).pipe( + Effect.tap((v) => Effect.sync(() => { - if (previous === undefined) delete process.env["DOTENV_PRIVATE_KEY"]; - else process.env["DOTENV_PRIVATE_KEY"] = previous; - rmSync(dir, { recursive: true, force: true }); + expect(v.vault).toEqual([{ name: "my_secret", value: "value", resolved: true }]); }), ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("fails the load for an encrypted: [db.vault] secret with no private key", () => { // Go aborts the whole command (`failed to parse config: missing private key`) // rather than silently skipping the secret. - const previous = process.env["DOTENV_PRIVATE_KEY"]; - delete process.env["DOTENV_PRIVATE_KEY"]; const dir = withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")); - return read(dir).pipe( + return read(dir, {}).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse config: missing private key", ); } }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous !== undefined) process.env["DOTENV_PRIVATE_KEY"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -227,22 +341,14 @@ describe("legacyReadDbToml", () => { it.effect("honors SUPABASE_DB_SEED_SQL_PATHS over the TOML array (comma split, no trim)", () => { // Go's StringToSliceHookFunc(",") splits without trimming, so " b.sql" keeps its space. - const previous = process.env["SUPABASE_DB_SEED_SQL_PATHS"]; - process.env["SUPABASE_DB_SEED_SQL_PATHS"] = "a.sql, b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["ignored.sql"]', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_SEED_SQL_PATHS: "a.sql, b.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.sqlPaths).toEqual(["supabase/a.sql", "supabase/ b.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_SEED_SQL_PATHS"]; - else process.env["SUPABASE_DB_SEED_SQL_PATHS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -277,22 +383,14 @@ describe("legacyReadDbToml", () => { () => { // Go runs LoadEnvHook before StringToSliceHookFunc(","), so env(SEEDS)=a.sql,b.sql // expands first and then splits into two patterns. - const previous = process.env["SEEDS"]; - process.env["SEEDS"] = "a.sql,b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = "env(SEEDS)"', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SEEDS: "a.sql,b.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.sqlPaths).toEqual(["supabase/a.sql", "supabase/b.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SEEDS"]; - else process.env["SEEDS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }, ); @@ -300,22 +398,14 @@ describe("legacyReadDbToml", () => { it.effect("expands an env() array element but does NOT split it (Go array asymmetry)", () => { // A TOML array element is decoded string→string: LoadEnvHook expands it, but // StringToSliceHookFunc does not fire, so it stays one (comma-containing) pattern. - const previous = process.env["SEEDS"]; - process.env["SEEDS"] = "a.sql,b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["env(SEEDS)"]', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SEEDS: "a.sql,b.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.sqlPaths).toEqual(["supabase/a.sql,b.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SEEDS"]; - else process.env["SEEDS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -340,22 +430,14 @@ describe("legacyReadDbToml", () => { it.effect( "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", () => { - const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "a.sql, b.sql"; const dir = withConfig(["[db.migrations]", 'schema_paths = ["ignored.sql"]', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "a.sql, b.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.schemaPaths).toEqual(["supabase/a.sql", "supabase/ b.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }, ); @@ -544,7 +626,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( `'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '${goType}'`, ); } @@ -574,7 +656,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'time.Time'", ); } @@ -595,7 +677,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type 'time.Time'", ); } @@ -620,7 +702,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type 'map[string]interface {}'", ); } @@ -665,7 +747,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse config: decoding failed due to the following error(s):\\n\\n'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", ); } @@ -691,7 +773,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'map[string]interface {}'", ); } @@ -712,7 +794,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", ); } @@ -748,7 +830,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const message = JSON.stringify(exit.cause); + const message = Formatter.formatJson(exit.cause); const schemaIssue = "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; const seedIssue = @@ -772,8 +854,6 @@ describe("legacyReadDbToml", () => { // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv, // so an explicit remote value wins over the env var. const ref = "schmschmschmschmschm"; - const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "env-only.sql"; const dir = withConfig( [ "[remotes.prod]", @@ -782,19 +862,13 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-only.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.schemaPaths).toEqual(["supabase/remote-only.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }, ); @@ -846,7 +920,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Missing required field in config: db.port", ); } @@ -860,24 +934,16 @@ describe("legacyReadDbToml", () => { // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv, // so an explicit remote value wins over the env var. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; - process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; const dir = withConfig( ["[remotes.prod]", `project_id = "${ref}"`, "db.migrations.enabled = true", ""].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_DB_MIGRATIONS_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.migrationsEnabled).toBe(true); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; - else process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -885,22 +951,14 @@ describe("legacyReadDbToml", () => { // Control: the env override is suppressed only for keys the matched block explicitly // set; a block that omits db.migrations.enabled leaves the env override in force. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; - process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_DB_MIGRATIONS_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.migrationsEnabled).toBe(false); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; - else process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -933,22 +991,14 @@ describe("legacyReadDbToml", () => { it.effect( "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", () => { - const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "a.sql, b.sql"; const dir = withConfig(["[db.migrations]", 'schema_paths = ["ignored.sql"]', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "a.sql, b.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.schemaPaths).toEqual(["supabase/a.sql", "supabase/ b.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }, ); @@ -970,8 +1020,6 @@ describe("legacyReadDbToml", () => { () => { // Same override-tier precedence as db.migrations.enabled above. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "env-wins.sql"; const dir = withConfig( [ "[remotes.prod]", @@ -981,19 +1029,13 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS: "env-wins.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.schemaPaths).toEqual(["supabase/remote-wins.sql"]); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; - else process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }, ); @@ -1003,8 +1045,6 @@ describe("legacyReadDbToml", () => { // not just db/seed — so a remote experimental.pgdelta.enabled wins // over SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "false"; const dir = withConfig( [ "[remotes.prod]", @@ -1014,19 +1054,13 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.pgDelta.enabled).toBe(true); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1034,22 +1068,14 @@ describe("legacyReadDbToml", () => { // Control: the env override is suppressed only for keys the matched block explicitly set; // a block that omits experimental.pgdelta.enabled leaves the env override in force. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "true"; const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "true" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.pgDelta.enabled).toBe(true); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1058,8 +1084,6 @@ describe("legacyReadDbToml", () => { // but for auth.enabled specifically (CLI-1878): a matched remote // block's auth.enabled must win over SUPABASE_AUTH_ENABLED. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_AUTH_ENABLED"]; - process.env["SUPABASE_AUTH_ENABLED"] = "true"; const dir = withConfig( [ "[remotes.prod]", @@ -1069,19 +1093,13 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_AUTH_ENABLED: "true" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.baseline.authEnabled).toBe(false); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; - else process.env["SUPABASE_AUTH_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1089,22 +1107,14 @@ describe("legacyReadDbToml", () => { // Control: the env override is suppressed only for keys the matched block explicitly // set; a block that omits auth.enabled leaves the env override in force. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_AUTH_ENABLED"]; - process.env["SUPABASE_AUTH_ENABLED"] = "false"; const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_AUTH_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.baseline.authEnabled).toBe(false); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; - else process.env["SUPABASE_AUTH_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1117,8 +1127,6 @@ describe("legacyReadDbToml", () => { // (false) would win instead, and the merged [experimental.webhooks] section (present via // the remote block) would then fail validation ("Webhooks cannot be deactivated"). const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "false"; const dir = withConfig( [ "[remotes.prod]", @@ -1128,15 +1136,12 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED: "false" }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isSuccess(exit)).toBe(true); if (Exit.isSuccess(exit)) expect(exit.value.webhooksEnabled).toBe(true); - if (previous === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = previous; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1152,8 +1157,6 @@ describe("legacyReadDbToml", () => { // base [experimental.webhooks] section (present, default true) flipped off by the env // var still fails Go's "cannot be deactivated" validation. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "false"; const dir = withConfig( [ "[experimental.webhooks]", @@ -1163,17 +1166,14 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED: "false" }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("Webhooks cannot be deactivated"); + expect(Formatter.formatJson(exit.cause)).toContain("Webhooks cannot be deactivated"); } - if (previous === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = previous; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1191,18 +1191,13 @@ describe("legacyReadDbToml", () => { // section is declared, unlike experimental.pgdelta.enabled (always known via the Eject // template merged into defaults). Before the presence gate, this reader parsed the env // override unconditionally and failed the whole config load on the bogus value. - const previous = process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "bogus"; const dir = withConfig(""); - return read(dir).pipe( + return read(dir, { SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED: "bogus" }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isSuccess(exit)).toBe(true); if (Exit.isSuccess(exit)) expect(exit.value.webhooksEnabled).toBe(false); - if (previous === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = previous; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1214,22 +1209,14 @@ describe("legacyReadDbToml", () => { // Viper AutomaticEnv supplies/overrides remotes.prod.project_id, so the block merges // even with no TOML project_id (here it lifts major_version 15 over the base default). const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"]; - process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"] = ref; const dir = withConfig(["[remotes.prod]", "db.major_version = 15", ""].join("\n")); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_REMOTES_PROD_PROJECT_ID: ref }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.majorVersion).toBe(15); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"]; - else process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1237,10 +1224,8 @@ describe("legacyReadDbToml", () => { // Without the env value the block (no TOML project_id) would fail Validate; the env // override supplies a valid ref, so the load succeeds. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"]; - process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"] = ref; const dir = withConfig(["[remotes.prod]", "db.major_version = 15", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_REMOTES_PROD_PROJECT_ID: ref }).pipe( Effect.tap((v) => Effect.sync(() => { // Load succeeded (no invalid-remote error); read() without a ref leaves the base @@ -1248,13 +1233,7 @@ describe("legacyReadDbToml", () => { expect(v.majorVersion).toBe(17); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"]; - else process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1262,43 +1241,27 @@ describe("legacyReadDbToml", () => { // Go's mergeRemoteConfig v.Set(false) is an override-tier value above AutomaticEnv, // so a remote that omits db.seed.enabled stays unseeded even with the env var set. const ref = "abcdefghijklmnopqrst"; - const previous = process.env["SUPABASE_DB_SEED_ENABLED"]; - process.env["SUPABASE_DB_SEED_ENABLED"] = "true"; const dir = withConfig(["[remotes.prod]", `project_id = "${ref}"`, ""].join("\n")); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_DB_SEED_ENABLED: "true" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.enabled).toBe(false); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_SEED_ENABLED"]; - else process.env["SUPABASE_DB_SEED_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("SUPABASE_DB_SEED_ENABLED still wins on the local path (no remote force)", () => { // Negative control: with no matched remote block, the env override applies normally. - const previous = process.env["SUPABASE_DB_SEED_ENABLED"]; - process.env["SUPABASE_DB_SEED_ENABLED"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_SEED_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.enabled).toBe(false); }), ), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_DB_SEED_ENABLED"]; - else process.env["SUPABASE_DB_SEED_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1336,7 +1299,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDbConfigLoadError"); } rmSync(dir, { recursive: true, force: true }); }), @@ -1460,7 +1423,9 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("duplicate project_id for [remotes.b]"); + expect(Formatter.formatJson(exit.cause)).toContain( + "duplicate project_id for [remotes.b]", + ); } rmSync(dir, { recursive: true, force: true }); }), @@ -1478,7 +1443,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Failed reading config: Invalid edge_runtime.deno_version: 3.", ); } @@ -1496,7 +1461,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Missing required field in config: edge_runtime.deno_version", ); } @@ -1529,7 +1494,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigLoadError"); expect(json).toContain( "Invalid config for experimental.pgdelta.format_options: must be valid JSON", @@ -1562,7 +1527,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigLoadError"); // Prose part is backslash-free, so safe to assert through JSON.stringify; // the trailing `(<regex source>)` is built from the pattern's `.source`, @@ -1588,7 +1553,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigLoadError"); expect(json).toContain( "Invalid Function name: 123. Must start with at least one letter, and only include alphanumeric characters, underscores, and hyphens.", @@ -1630,7 +1595,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigLoadError"); expect(json).toContain("invalid storage.buckets.avatars.file_size_limit"); } @@ -1686,7 +1651,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); + const json = Formatter.formatJson(exit.cause); expect(json).toContain("LegacyDbConfigLoadError"); expect(json).toContain("failed to parse config: invalid api.auto_expose_new_tables."); } @@ -1699,49 +1664,30 @@ describe("legacyReadDbToml", () => { it.effect("honors SUPABASE_API_AUTO_EXPOSE_NEW_TABLES env override (AutomaticEnv)", () => { // viper AutomaticEnv overrides the TOML value; `1` decodes to true. const dir = withConfig("[api]\nauto_expose_new_tables = false\n"); - const saved = process.env["SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"]; - process.env["SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"] = "1"; - return read(dir).pipe( + return read(dir, { SUPABASE_API_AUTO_EXPOSE_NEW_TABLES: "1" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.baseline.apiAutoExposeNewTables)).toBe(true); }), ), - Effect.ensuring( - Effect.sync(() => { - if (saved === undefined) delete process.env["SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"]; - else process.env["SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"] = saved; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("honors SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED / _DECLARATIVE_SCHEMA_PATH env", () => { // Go's viper AutomaticEnv overrides TOML for experimental.pgdelta.* before validation. const dir = withConfig(undefined); - const savedEnabled = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - const savedPath = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "true"; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"] = "from_env"; - return read(dir).pipe( + return read(dir, { + SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "true", + SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH: "from_env", + }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.pgDelta.enabled).toBe(true); expect(Option.getOrNull(v.pgDelta.declarativeSchemaPath)).toBe("supabase/from_env"); }), ), - Effect.ensuring( - Effect.sync(() => { - if (savedEnabled === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = savedEnabled; - if (savedPath === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"] = savedPath; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -1749,70 +1695,44 @@ describe("legacyReadDbToml", () => { // Go decodes the AutomaticEnv override through LoadEnvHook, so an // env(VAR) indirection resolves before the supabase/ join — not stored literally. const dir = withConfig(undefined); - const savedEnabled = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - const savedPath = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"]; - const savedDir = process.env["SCHEMA_DIR"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "true"; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"] = "env(SCHEMA_DIR)"; - process.env["SCHEMA_DIR"] = "schemas"; - return read(dir).pipe( + return read(dir, { + SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "true", + SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH: "env(SCHEMA_DIR)", + SCHEMA_DIR: "schemas", + }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.pgDelta.declarativeSchemaPath)).toBe("supabase/schemas"); }), ), - Effect.ensuring( - Effect.sync(() => { - if (savedEnabled === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = savedEnabled; - if (savedPath === undefined) - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"] = savedPath; - if (savedDir === undefined) delete process.env["SCHEMA_DIR"]; - else process.env["SCHEMA_DIR"] = savedDir; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("treats SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=1 as true (Go strconv.ParseBool)", () => { const dir = withConfig(undefined); - const saved = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "1"; - return read(dir).pipe( + return read(dir, { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "1" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.pgDelta.enabled).toBe(true); }), ), - Effect.ensuring( - Effect.sync(() => { - if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = saved; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("fails on a malformed SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED (Go config error)", () => { const dir = withConfig(undefined); - const saved = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "maybe"; - return read(dir).pipe( + return read(dir, { SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED: "maybe" }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse config: invalid experimental.pgdelta.enabled: maybe.", ); } - if (saved === undefined) delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; - else process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = saved; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1828,7 +1748,7 @@ describe("legacyReadDbToml", () => { const exit = yield* read(bad).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "failed to parse config: invalid storage.enabled.", ); } @@ -1841,7 +1761,7 @@ describe("legacyReadDbToml", () => { // Go's mergeFileConfig swallows only os.ErrNotExist; every other read error aborts // rather than silently running against the default local database (Codex P2 parity). // A directory at the config.toml path yields a non-NotFound PlatformError on read. - const dir = mkdtempSync(join(tmpdir(), "legacy-db-toml-")); + const dir = mkdtempSync("legacy-db-toml-"); mkdirSync(join(dir, "supabase", "config.toml"), { recursive: true }); return read(dir).pipe( Effect.exit, @@ -1849,8 +1769,8 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); - expect(JSON.stringify(exit.cause)).toContain("failed to read file config"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to read file config"); } rmSync(dir, { recursive: true, force: true }); }), @@ -1899,18 +1819,14 @@ describe("legacyReadDbToml", () => { }); it.effect("expands env(VAR) for password and port like Go's LoadEnvHook", () => { - process.env["LEGACY_DB_PW"] = "from-env"; - process.env["LEGACY_DB_PORT"] = "6000"; const dir = withConfig( ["[db]", 'port = "env(LEGACY_DB_PORT)"', 'password = "env(LEGACY_DB_PW)"', ""].join("\n"), ); - return read(dir).pipe( + return read(dir, { LEGACY_DB_PW: "from-env", LEGACY_DB_PORT: "6000" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.port).toBe(6000); expect(v.password).toBe("from-env"); - delete process.env["LEGACY_DB_PW"]; - delete process.env["LEGACY_DB_PORT"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1921,13 +1837,11 @@ describe("legacyReadDbToml", () => { // Go's LoadEnvHook expands env(VAR) on every string element of db.seed.sql_paths // during unmarshal, before resolve() prefixes relative patterns — so the glob is // the expanded value, not the literal `supabase/env(...)`. - process.env["LEGACY_SEED_SQL"] = "custom/data.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["env(LEGACY_SEED_SQL)"]', ""].join("\n")); - return read(dir).pipe( + return read(dir, { LEGACY_SEED_SQL: "custom/data.sql" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.sqlPaths).toEqual(["supabase/custom/data.sql"]); - delete process.env["LEGACY_SEED_SQL"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1935,13 +1849,11 @@ describe("legacyReadDbToml", () => { }); it.effect("honors SUPABASE_DB_SEED_ENABLED over the TOML value (Go AutomaticEnv)", () => { - process.env["SUPABASE_DB_SEED_ENABLED"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_SEED_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.enabled).toBe(false); - delete process.env["SUPABASE_DB_SEED_ENABLED"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1952,15 +1864,11 @@ describe("legacyReadDbToml", () => { // Go decodes the AutomaticEnv override through LoadEnvHook before the bool parse, // so `env(SEED_ON)` resolves to SEED_ON's value rather // than failing the load on a literal `env(...)` bool. - process.env["SUPABASE_DB_SEED_ENABLED"] = "env(SEED_ON)"; - process.env["SEED_ON"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_SEED_ENABLED: "env(SEED_ON)", SEED_ON: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.seed.enabled).toBe(false); - delete process.env["SUPABASE_DB_SEED_ENABLED"]; - delete process.env["SEED_ON"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1968,13 +1876,11 @@ describe("legacyReadDbToml", () => { }); it.effect("honors SUPABASE_DB_MIGRATIONS_ENABLED over the default (Go AutomaticEnv)", () => { - process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; const dir = withConfig(undefined); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_MIGRATIONS_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.migrationsEnabled).toBe(false); - delete process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -1982,14 +1888,12 @@ describe("legacyReadDbToml", () => { }); it.effect("fails the load on a malformed SUPABASE_DB_SEED_ENABLED override", () => { - process.env["SUPABASE_DB_SEED_ENABLED"] = "notabool"; const dir = withConfig(undefined); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_SEED_ENABLED: "notabool" }).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); - delete process.env["SUPABASE_DB_SEED_ENABLED"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2001,13 +1905,11 @@ describe("legacyReadDbToml", () => { () => { // Go expands `project_id` via LoadEnvHook before deriving local container names, // so a raw `env(...)` must not leak into `supabase_db_env_PROJECT_ID_`. - process.env["LEGACY_PROJECT_REF"] = "abcdefghijklmnopqrst"; const dir = withConfig(['project_id = "env(LEGACY_PROJECT_REF)"', ""].join("\n")); - return read(dir).pipe( + return read(dir, { LEGACY_PROJECT_REF: "abcdefghijklmnopqrst" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.projectId)).toBe("abcdefghijklmnopqrst"); - delete process.env["LEGACY_PROJECT_REF"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2021,7 +1923,6 @@ describe("legacyReadDbToml", () => { // during the later UnmarshalExact. So the block is NOT selected by its expanded ref and // does not merge (major_version stays the base 15), while Validate over the decoded, // expanded field still passes the load. - process.env["LEGACY_STAGING_REF"] = "stagingrefstagingref"; const dir = withConfig( [ 'project_id = "base"', @@ -2034,11 +1935,12 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return readRef(dir, "stagingrefstagingref").pipe( + return readRef(dir, "stagingrefstagingref", { + LEGACY_STAGING_REF: "stagingrefstagingref", + }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.majorVersion).toBe(15); // block not merged: matched on the raw env() literal - delete process.env["LEGACY_STAGING_REF"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2047,17 +1949,16 @@ describe("legacyReadDbToml", () => { it.effect("rejects an env-backed remote project_id that expands to nothing", () => { // An unset env() expands to the literal `env(...)`, which fails Go's ref pattern. - delete process.env["LEGACY_MISSING_REF"]; const dir = withConfig( ["[remotes.staging]", 'project_id = "env(LEGACY_MISSING_REF)"', ""].join("\n"), ); - return read(dir).pipe( + return read(dir, {}).pipe( Effect.exit, Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Invalid config for remotes.staging.project_id", ); } @@ -2068,7 +1969,6 @@ describe("legacyReadDbToml", () => { }); it.effect("parses experimental.orioledb_version (env-expanded) on a 15/17 project", () => { - process.env["LEGACY_ORIOLE_VER"] = "16.0.0.1"; const dir = withConfig( [ "[db]", @@ -2082,11 +1982,10 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return read(dir).pipe( + return read(dir, { LEGACY_ORIOLE_VER: "16.0.0.1" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.orioledbVersion)).toBe("16.0.0.1"); - delete process.env["LEGACY_ORIOLE_VER"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2096,7 +1995,6 @@ describe("legacyReadDbToml", () => { it.effect("warns (does not fail) for an unset S3 env on an OrioleDB project", () => { // Go's assertEnvLoaded prints `WARN: environment variable is unset: <NAME>` to // stderr for an S3 value still holding an unexpanded env(...), and returns nil. - delete process.env["LEGACY_S3_KEY"]; const writes: Array<string> = []; const original = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array): boolean => { @@ -2113,7 +2011,7 @@ describe("legacyReadDbToml", () => { "", ].join("\n"), ); - return read(dir).pipe( + return read(dir, {}).pipe( Effect.tap((v) => Effect.sync(() => { // Config load succeeds (warning only), and the orioledb version is parsed. @@ -2133,7 +2031,6 @@ describe("legacyReadDbToml", () => { // than once per invocation (an earlier, authoritative preflight call already // warned) — internal re-reads pass `warnOnUnresolvedEnv: false` so Go's // exactly-once `flags.LoadConfig` WARN isn't printed a second/third time. - delete process.env["LEGACY_S3_KEY_QUIET"]; const writes: Array<string> = []; const original = process.stderr.write.bind(process.stderr); process.stderr.write = ((chunk: string | Uint8Array): boolean => { @@ -2151,11 +2048,12 @@ describe("legacyReadDbToml", () => { ].join("\n"), ); return Effect.gen(function* () { + yield* flushFixture(dir); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* legacyReadDbToml(fs, path, dir, undefined, { warnOnUnresolvedEnv: false }); }).pipe( - Effect.provide(BunServices.layer), + Effect.provide(testServices({})), Effect.tap((v) => Effect.sync(() => { // Config load still succeeds and still resolves the value; only the @@ -2174,9 +2072,8 @@ describe("legacyReadDbToml", () => { // Go's LoadEnvHook only substitutes when len(os.Getenv(name)) > 0; otherwise it // preserves the literal string. Password is a plain string field, so an // unresolved env() ref stays literal (it is not validated like the ports). - delete process.env["LEGACY_DB_UNSET"]; const dir = withConfig(["[db]", 'password = "env(LEGACY_DB_UNSET)"', ""].join("\n")); - return read(dir).pipe( + return read(dir, {}).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.password).toBe("env(LEGACY_DB_UNSET)"); @@ -2191,7 +2088,6 @@ describe("legacyReadDbToml", () => { () => { // Go decodes [db].port into uint16 after LoadEnvHook; a present value that cannot // unmarshal aborts config loading rather than silently defaulting to 54322. - delete process.env["LEGACY_DB_UNSET"]; const cases = ['port = "abc"', "port = 70000", "port = -1", 'port = "env(LEGACY_DB_UNSET)"']; return Effect.forEach(cases, (line) => { const dir = withConfig(["[db]", line, ""].join("\n")); @@ -2201,14 +2097,14 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacyDbConfigLoadError"); - expect(JSON.stringify(exit.cause)).toContain("invalid db.port"); + expect(Formatter.formatJson(exit.cause)).toContain("LegacyDbConfigLoadError"); + expect(Formatter.formatJson(exit.cause)).toContain("invalid db.port"); } rmSync(dir, { recursive: true, force: true }); }), ), ); - }); + }).pipe(Effect.provide(testServices({}))); }, ); @@ -2220,7 +2116,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("invalid db.shadow_port"); + expect(Formatter.formatJson(exit.cause)).toContain("invalid db.shadow_port"); } rmSync(dir, { recursive: true, force: true }); }), @@ -2229,14 +2125,13 @@ describe("legacyReadDbToml", () => { }); it.effect("resolves env(VAR) from the project supabase/.env file (Go loadNestedEnv)", () => { - delete process.env["LEGACY_DB_FILEVAR"]; const dir = withConfig( ["[db]", 'port = "env(LEGACY_DB_FILEVAR)"', 'password = "env(LEGACY_DB_FILEVAR)"', ""].join( "\n", ), ); writeFileSync(join(dir, "supabase", ".env"), "LEGACY_DB_FILEVAR=7000\n"); - return read(dir).pipe( + return read(dir, {}).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.port).toBe(7000); @@ -2248,14 +2143,12 @@ describe("legacyReadDbToml", () => { }); it.effect("lets the shell env win over a project .env value (godotenv no-override)", () => { - process.env["LEGACY_DB_FILEVAR"] = "shell-wins"; const dir = withConfig(["[db]", 'password = "env(LEGACY_DB_FILEVAR)"', ""].join("\n")); writeFileSync(join(dir, "supabase", ".env"), "LEGACY_DB_FILEVAR=from-file\n"); - return read(dir).pipe( + return read(dir, { LEGACY_DB_FILEVAR: "shell-wins" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.password).toBe("shell-wins"); - delete process.env["LEGACY_DB_FILEVAR"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2263,11 +2156,10 @@ describe("legacyReadDbToml", () => { }); it.effect("lets supabase/.env win over a repo-root .env (Go walks supabase/ first)", () => { - delete process.env["LEGACY_DB_FILEVAR"]; const dir = withConfig(["[db]", 'password = "env(LEGACY_DB_FILEVAR)"', ""].join("\n")); writeFileSync(join(dir, ".env"), "LEGACY_DB_FILEVAR=root\n"); writeFileSync(join(dir, "supabase", ".env"), "LEGACY_DB_FILEVAR=supabase\n"); - return read(dir).pipe( + return read(dir, {}).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.password).toBe("supabase"); @@ -2286,7 +2178,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to parse environment file"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to parse environment file"); } rmSync(dir, { recursive: true, force: true }); }), @@ -2306,7 +2198,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to read environment file"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to read environment file"); } rmSync(dir, { recursive: true, force: true }); }), @@ -2315,18 +2207,14 @@ describe("legacyReadDbToml", () => { }); it.effect("lets SUPABASE_DB_* env vars override the [db] config (viper AutomaticEnv)", () => { - const prev = { - PORT: process.env["SUPABASE_DB_PORT"], - SHADOW: process.env["SUPABASE_DB_SHADOW_PORT"], - PW: process.env["SUPABASE_DB_PASSWORD"], - }; - process.env["SUPABASE_DB_PORT"] = "6000"; - process.env["SUPABASE_DB_SHADOW_PORT"] = "6001"; - process.env["SUPABASE_DB_PASSWORD"] = "env-override"; const dir = withConfig( ["[db]", "port = 55555", "shadow_port = 55556", 'password = "hunter2"', ""].join("\n"), ); - return read(dir).pipe( + return read(dir, { + SUPABASE_DB_PORT: "6000", + SUPABASE_DB_SHADOW_PORT: "6001", + SUPABASE_DB_PASSWORD: "env-override", + }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.port).toBe(6000); @@ -2334,14 +2222,6 @@ describe("legacyReadDbToml", () => { // db.password is tagged `json:"-"` in Go, so it is NOT bound from // SUPABASE_DB_PASSWORD — the local password stays the config value. expect(v.password).toBe("hunter2"); - for (const [k, val] of Object.entries({ - SUPABASE_DB_PORT: prev.PORT, - SUPABASE_DB_SHADOW_PORT: prev.SHADOW, - SUPABASE_DB_PASSWORD: prev.PW, - })) { - if (val === undefined) delete process.env[k]; - else process.env[k] = val; - } rmSync(dir, { recursive: true, force: true }); }), ), @@ -2350,15 +2230,11 @@ describe("legacyReadDbToml", () => { it.effect("does not source the local password from SUPABASE_DB_PASSWORD", () => { // Go's db.Password is json:"-" — not env-bound; the local default is "postgres". - const prev = process.env["SUPABASE_DB_PASSWORD"]; - process.env["SUPABASE_DB_PASSWORD"] = "remote-secret"; const dir = withConfig(["[db]", "port = 5000", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_PASSWORD: "remote-secret" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.password).toBe("postgres"); - if (prev === undefined) delete process.env["SUPABASE_DB_PASSWORD"]; - else process.env["SUPABASE_DB_PASSWORD"] = prev; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2377,7 +2253,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Missing required field in config: db.major_version", ); } @@ -2395,7 +2271,9 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("Postgres version 12.x is unsupported"); + expect(Formatter.formatJson(exit.cause)).toContain( + "Postgres version 12.x is unsupported", + ); } rmSync(dir, { recursive: true, force: true }); }), @@ -2411,7 +2289,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Failed reading config: Invalid db.major_version: 16.", ); } @@ -2443,7 +2321,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Failed reading config: Invalid db.major_version: 17foo.", ); } @@ -2454,13 +2332,11 @@ describe("legacyReadDbToml", () => { }); it.effect("expands env(VAR) for db.major_version like Go's LoadEnvHook", () => { - process.env["LEGACY_PG_MAJOR"] = "15"; const dir = withConfig(["[db]", 'major_version = "env(LEGACY_PG_MAJOR)"', ""].join("\n")); - return read(dir).pipe( + return read(dir, { LEGACY_PG_MAJOR: "15" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.majorVersion).toBe(15); - delete process.env["LEGACY_PG_MAJOR"]; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2468,15 +2344,11 @@ describe("legacyReadDbToml", () => { }); it.effect("honors SUPABASE_DB_MAJOR_VERSION over the TOML value", () => { - const prev = process.env["SUPABASE_DB_MAJOR_VERSION"]; - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; const dir = withConfig(["[db]", "major_version = 17", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_MAJOR_VERSION: "15" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.majorVersion).toBe(15); - if (prev === undefined) delete process.env["SUPABASE_DB_MAJOR_VERSION"]; - else process.env["SUPABASE_DB_MAJOR_VERSION"] = prev; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2486,15 +2358,11 @@ describe("legacyReadDbToml", () => { it.effect("honors SUPABASE_EDGE_RUNTIME_DENO_VERSION over the TOML value", () => { // Go binds this via viper AutomaticEnv before Validate, so an env override of 1 // selects the deno1 edge-runtime image even when the TOML omits/sets a different value. - const prev = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; const dir = withConfig(["[edge_runtime]", "deno_version = 2", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_EDGE_RUNTIME_DENO_VERSION: "1" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.denoVersion).toBe(1); - if (prev === undefined) delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - else process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = prev; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2511,7 +2379,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Failed reading config: Invalid edge_runtime.deno_version: 2foo.", ); } @@ -2531,7 +2399,7 @@ describe("legacyReadDbToml", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Invalid config for remotes.staging.project_id. Must be like: abcdefghijklmnopqrst", ); } @@ -2556,15 +2424,11 @@ describe("legacyReadDbToml", () => { }); it.effect("ignores an empty SUPABASE_DB_PORT override (viper AllowEmptyEnv=false)", () => { - const prev = process.env["SUPABASE_DB_PORT"]; - process.env["SUPABASE_DB_PORT"] = ""; const dir = withConfig(["[db]", "port = 55555", ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_DB_PORT: "" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.port).toBe(55555); - if (prev === undefined) delete process.env["SUPABASE_DB_PORT"]; - else process.env["SUPABASE_DB_PORT"] = prev; rmSync(dir, { recursive: true, force: true }); }), ), @@ -2576,11 +2440,10 @@ describe("legacyReadDbToml", () => { () => { // The --linked resolver reads SUPABASE_DB_PASSWORD via this map, so a value // defined only in supabase/.env must be visible (Go's loadNestedEnv parity). - delete process.env["SUPABASE_DB_PASSWORD"]; - const dir = mkdtempSync(join(tmpdir(), "legacy-db-toml-")); + const dir = mkdtempSync("legacy-db-toml-"); mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync(join(dir, "supabase", ".env"), "SUPABASE_DB_PASSWORD=from-dotenv\n"); - return loadEnv(dir).pipe( + return loadEnv(dir, {}).pipe( Effect.tap((env) => Effect.sync(() => { expect(env["SUPABASE_DB_PASSWORD"]).toBe("from-dotenv"); @@ -2597,32 +2460,27 @@ describe("legacyReadDbToml", () => { // NOT mutate process.env. Applying to process.env is the separate, opt-in // legacyApplyProjectEnv (below), so a mere `load` for SUPABASE_YES has no global // side effect. - const saved: Record<string, string | undefined> = {}; - for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { - saved[k] = process.env[k]; - delete process.env[k]; - } - const dir = mkdtempSync(join(tmpdir(), "legacy-db-toml-")); + const dir = mkdtempSync("legacy-db-toml-"); mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync( join(dir, "supabase", ".env"), "SUPABASE_INTERNAL_IMAGE_REGISTRY=my-mirror.example.com\nSUPABASE_PROJECT_ID=envonlyref\nSUPABASE_ENV=staging\n", ); - return loadEnv(dir).pipe( - Effect.tap((env) => - Effect.sync(() => { + return loadEnv(dir, {}).pipe( + Effect.flatMap((env) => + Effect.gen(function* () { // The returned map carries all keys. expect(env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); expect(env["SUPABASE_PROJECT_ID"]).toBe("envonlyref"); expect(env["SUPABASE_ENV"]).toBe("staging"); - // ...but process.env is untouched, including the allowlisted registry key. - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); - expect(process.env["SUPABASE_ENV"]).toBeUndefined(); - for (const [k, v] of Object.entries(saved)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } + // ...but the process environment is untouched, including the allowlisted registry key. + expect( + Option.getOrUndefined(yield* readEnvironment("SUPABASE_INTERNAL_IMAGE_REGISTRY")), + ).toBeUndefined(); + expect( + Option.getOrUndefined(yield* readEnvironment("SUPABASE_PROJECT_ID")), + ).toBeUndefined(); + expect(Option.getOrUndefined(yield* readEnvironment("SUPABASE_ENV"))).toBeUndefined(); rmSync(dir, { recursive: true, force: true }); }), ), @@ -2630,25 +2488,8 @@ describe("legacyReadDbToml", () => { }); it.effect( - "legacyApplyProjectEnv sets only the allowlisted keys in-scope, never overrides, reverts on close", + "legacyApplyProjectEnv resolves only allowlisted keys and lets shell values win", () => { - // Go's loadNestedEnv os.Setenv's the project .env, but its root globals - // (project-ref, SUPABASE_ENV, workdir/profile) are resolved from the shell - // BEFORE loadNestedEnv. Our resolvers read process.env lazily, so we apply only - // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` / `PGDELTA_NPM_REGISTRY` - // (the two process.env-only readers): a .env project-ref must not retarget the - // lazy ref/pooler resolvers, and a .env SUPABASE_ENV must not switch the - // env-file set. - const saved: Record<string, string | undefined> = {}; - for (const k of [ - "SUPABASE_INTERNAL_IMAGE_REGISTRY", - "PGDELTA_NPM_REGISTRY", - "SUPABASE_PROJECT_ID", - "SUPABASE_ENV", - ]) { - saved[k] = process.env[k]; - delete process.env[k]; - } const loaded = { SUPABASE_INTERNAL_IMAGE_REGISTRY: "my-mirror.example.com", PGDELTA_NPM_REGISTRY: "https://npm.example.com", @@ -2656,34 +2497,23 @@ describe("legacyReadDbToml", () => { SUPABASE_ENV: "staging", }; return Effect.gen(function* () { - // Inside the scope: only the registry keys are applied; the ref/env selector are not. - yield* Effect.scoped( - Effect.gen(function* () { - yield* legacyApplyProjectEnv(loaded); - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBe("https://npm.example.com"); - expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); - expect(process.env["SUPABASE_ENV"]).toBeUndefined(); - }), + const resolved = yield* legacyApplyProjectEnv(loaded); + expect(resolved).toEqual({ + SUPABASE_INTERNAL_IMAGE_REGISTRY: "my-mirror.example.com", + PGDELTA_NPM_REGISTRY: "https://npm.example.com", + }); + const shellResolved = yield* legacyApplyProjectEnv(loaded).pipe( + Effect.provide( + testServices({ + SUPABASE_INTERNAL_IMAGE_REGISTRY: "shell-wins.example.com", + }), + ), ); - // After the scope closes the applied keys are reverted (no test-worker leak). - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - expect(process.env["PGDELTA_NPM_REGISTRY"]).toBeUndefined(); - - // An existing process.env value is never overridden, and is NOT deleted on close. - process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = "shell-wins.example.com"; - yield* Effect.scoped(legacyApplyProjectEnv(loaded)); - expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("shell-wins.example.com"); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - for (const [k, v] of Object.entries(saved)) { - if (v === undefined) delete process.env[k]; - else process.env[k] = v; - } - }), - ), - ); + expect(shellResolved).toEqual({ + SUPABASE_INTERNAL_IMAGE_REGISTRY: "shell-wins.example.com", + PGDELTA_NPM_REGISTRY: "https://npm.example.com", + }); + }).pipe(Effect.provide(testServices({}))); }, ); @@ -2848,7 +2678,7 @@ describe("legacyReadDbToml auth.Enabled validation (Go config.Validate parity)", if (extra) extra(dir); const exit = yield* read(dir).pipe(Effect.exit); expect(Exit.isFailure(exit), `expected failure containing: ${message}`).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain(message); + if (Exit.isFailure(exit)) expect(Formatter.formatJson(exit.cause)).toContain(message); rmSync(dir, { recursive: true, force: true }); }); // Loads cleanly — no validation error (the read resolves to a value). @@ -3059,20 +2889,12 @@ describe("legacyReadDbToml auth.Enabled validation (Go config.Validate parity)", ); it.effect("skips auth validation when SUPABASE_AUTH_ENABLED=false (env override)", () => { - const previous = process.env["SUPABASE_AUTH_ENABLED"]; - process.env["SUPABASE_AUTH_ENABLED"] = "false"; const dir = withConfig( ["[auth]", 'site_url = ""', "[auth.passkey]", "enabled = true"].join("\n"), ); - return read(dir).pipe( + return read(dir, { SUPABASE_AUTH_ENABLED: "false" }).pipe( Effect.tap((v) => Effect.sync(() => expect(v.baseline).toBeDefined())), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_AUTH_ENABLED"]; - else process.env["SUPABASE_AUTH_ENABLED"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -3099,7 +2921,7 @@ describe("legacyReadDbToml encrypted secret decryption (Go DecryptSecretHookFunc const dir = withConfig(lines.join("\n")); const exit = yield* read(dir).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain(message); + if (Exit.isFailure(exit)) expect(Formatter.formatJson(exit.cause)).toContain(message); rmSync(dir, { recursive: true, force: true }); }); const expectLoads = (lines: ReadonlyArray<string>) => @@ -3185,7 +3007,9 @@ describe("legacyReadDbToml non-scalar config booleans (Go UnmarshalExact parity) const exit = yield* read(dir).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain(`failed to parse config: invalid ${field}.`); + expect(Formatter.formatJson(exit.cause)).toContain( + `failed to parse config: invalid ${field}.`, + ); } rmSync(dir, { recursive: true, force: true }); }); @@ -3208,7 +3032,7 @@ describe("legacyReadDbToml empty project_id (Go config.Validate parity)", () => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "Missing required field in config: project_id", ); } @@ -3232,12 +3056,16 @@ describe("legacyReadDbToml empty project_id (Go config.Validate parity)", () => }); describe("legacyReadDbToml [analytics] validation (Go config.Validate parity)", () => { - const failsWith = (lines: ReadonlyArray<string>, message: string) => + const failsWith = ( + lines: ReadonlyArray<string>, + message: string, + env: Readonly<Record<string, string>> = {}, + ) => Effect.gen(function* () { const dir = withConfig(lines.join("\n")); - const exit = yield* read(dir).pipe(Effect.exit); + const exit = yield* read(dir, env).pipe(Effect.exit); expect(Exit.isFailure(exit), `expected failure containing: ${message}`).toBe(true); - if (Exit.isFailure(exit)) expect(JSON.stringify(exit.cause)).toContain(message); + if (Exit.isFailure(exit)) expect(Formatter.formatJson(exit.cause)).toContain(message); rmSync(dir, { recursive: true, force: true }); }); const succeeds = (lines: ReadonlyArray<string>) => @@ -3299,84 +3127,49 @@ describe("legacyReadDbToml [analytics] validation (Go config.Validate parity)", it.effect("skips the bigquery gcp checks when analytics is disabled", () => succeeds(["[analytics]", "enabled = false", 'backend = "bigquery"']), ); - it.effect("honors SUPABASE_ANALYTICS_BACKEND when validating the bigquery gcp fields", () => { - const previous = process.env["SUPABASE_ANALYTICS_BACKEND"]; - process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; - return failsWith( + it.effect("honors SUPABASE_ANALYTICS_BACKEND when validating the bigquery gcp fields", () => + failsWith( ["[analytics]", "enabled = true"], "Missing required field in config: analytics.gcp_project_id", - ).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_ANALYTICS_BACKEND"]; - else process.env["SUPABASE_ANALYTICS_BACKEND"] = previous; - }), - ), - ); - }); + { SUPABASE_ANALYTICS_BACKEND: "bigquery" }, + ), + ); }); describe("legacyReadDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity)", () => { - const restore = (previous: string | undefined) => - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_PROJECT_ID"]; - else process.env["SUPABASE_PROJECT_ID"] = previous; - }); - it.effect("overrides the TOML project_id with SUPABASE_PROJECT_ID", () => { - const previous = process.env["SUPABASE_PROJECT_ID"]; - process.env["SUPABASE_PROJECT_ID"] = "env-project"; const dir = withConfig(['project_id = "toml-project"', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_PROJECT_ID: "env-project" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.projectId)).toBe("env-project"); }), ), - Effect.ensuring( - Effect.sync(() => { - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.ensuring(restore(previous)), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("applies SUPABASE_PROJECT_ID even when config.toml is absent", () => { - const previous = process.env["SUPABASE_PROJECT_ID"]; - process.env["SUPABASE_PROJECT_ID"] = "env-project"; const dir = withConfig(undefined); - return read(dir).pipe( + return read(dir, { SUPABASE_PROJECT_ID: "env-project" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.projectId)).toBe("env-project"); }), ), - Effect.ensuring( - Effect.sync(() => { - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.ensuring(restore(previous)), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); it.effect("ignores an empty SUPABASE_PROJECT_ID (viper AllowEmptyEnv=false)", () => { - const previous = process.env["SUPABASE_PROJECT_ID"]; - process.env["SUPABASE_PROJECT_ID"] = ""; const dir = withConfig(['project_id = "toml-project"', ""].join("\n")); - return read(dir).pipe( + return read(dir, { SUPABASE_PROJECT_ID: "" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(Option.getOrNull(v.projectId)).toBe("toml-project"); }), ), - Effect.ensuring( - Effect.sync(() => { - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.ensuring(restore(previous)), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); @@ -3388,13 +3181,11 @@ describe("legacyReadDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity) // that block is selected BECAUSE its // `project_id` equals the resolved ref, so it must win even when an unrelated // `SUPABASE_PROJECT_ID` is set to something else entirely. - const previous = process.env["SUPABASE_PROJECT_ID"]; - process.env["SUPABASE_PROJECT_ID"] = "local"; const ref = "abcdefghijklmnopqrst"; const dir = withConfig( ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), ); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_PROJECT_ID: "local" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.appliedRemote).toBe("prod"); @@ -3402,34 +3193,22 @@ describe("legacyReadDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity) expect(Option.getOrNull(v.projectId)).toBe(ref); }), ), - Effect.ensuring( - Effect.sync(() => { - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.ensuring(restore(previous)), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }, ); it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { - const previous = process.env["SUPABASE_PROJECT_ID"]; - process.env["SUPABASE_PROJECT_ID"] = "env-project"; const ref = "abcdefghijklmnopqrst"; const dir = withConfig(['project_id = "toml-project"', ""].join("\n")); - return readRef(dir, ref).pipe( + return readRef(dir, ref, { SUPABASE_PROJECT_ID: "env-project" }).pipe( Effect.tap((v) => Effect.sync(() => { expect(v.appliedRemote).toBeUndefined(); expect(Option.getOrNull(v.projectId)).toBe("env-project"); }), ), - Effect.ensuring( - Effect.sync(() => { - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.ensuring(restore(previous)), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 1193d02ebf..37734650ae 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -7,8 +7,9 @@ * node-postgres error shapes, not libpq wording. */ import * as net from "node:net"; +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Duration, Effect } from "effect"; +import { Duration, Effect, Layer } from "effect"; import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; @@ -61,17 +62,22 @@ const connectFailure = ( Effect.mapError(() => new Error("expected the connection to fail")), Effect.orDie, ); - }).pipe(Effect.provide(legacyDbConnectionSqlPgLayer)); + }).pipe(Effect.provide(legacyDbConnectionSqlPgLayer.pipe(Layer.provide(BunServices.layer)))); /** A TCP port that is guaranteed closed: bind an ephemeral port, then release it. */ -const acquireClosedPort = (): Promise<number> => - new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const address = server.address() as net.AddressInfo; - server.close((error) => (error === undefined ? resolve(address.port) : reject(error))); - }); +const acquireClosedPort = Effect.callback<number, Error>((resume) => { + const server = net.createServer(); + const close = () => { + if (server.listening) server.close(); + }; + server.listen(0, "127.0.0.1", () => { + const address = server.address() as net.AddressInfo; + server.close((error) => + error === undefined ? resume(Effect.succeed(address.port)) : resume(Effect.fail(error)), + ); }); + return Effect.sync(close); +}); /** Encode a Postgres wire-protocol ErrorResponse ('E') message. */ const errorResponse = (fields: Record<string, string>): Buffer => { @@ -92,10 +98,8 @@ const errorResponse = (fields: Record<string, string>): Buffer => { * first startup message to `onStartup`, so tests can drive the real driver * through real server-side failure shapes. */ -const fakePostgresServer = ( - onStartup: (socket: net.Socket) => void, -): Promise<{ readonly port: number; readonly close: () => void }> => - new Promise((resolve) => { +const fakePostgresServer = (onStartup: (socket: net.Socket) => void) => + Effect.callback<{ readonly port: number; readonly close: () => void }, Error>((resume) => { const server = net.createServer((socket) => { let sawStartup = false; socket.on("data", (data: Buffer) => { @@ -113,7 +117,10 @@ const fakePostgresServer = ( }); server.listen(0, "127.0.0.1", () => { const address = server.address() as net.AddressInfo; - resolve({ port: address.port, close: () => server.close() }); + resume(Effect.succeed({ port: address.port, close: () => server.close() })); + }); + return Effect.sync(() => { + if (server.listening) server.close(); }); }); @@ -177,12 +184,15 @@ const fakeBatchServer = ( /** Never answer an extended-protocol frame, so a batch hangs until interrupted. */ readonly stall?: boolean; } = {}, -): Promise<{ - readonly port: number; - readonly close: () => void; - readonly state: FakeBatchServerState; -}> => - new Promise((resolve) => { +): Effect.Effect< + { + readonly port: number; + readonly close: () => void; + readonly state: FakeBatchServerState; + }, + Error +> => + Effect.callback((resume) => { const state: FakeBatchServerState = { frameTypes: [], statements: [], @@ -288,7 +298,10 @@ const fakeBatchServer = ( }); server.listen(0, "127.0.0.1", () => { const address = server.address() as net.AddressInfo; - resolve({ port: address.port, close: () => server.close(), state }); + resume(Effect.succeed({ port: address.port, close: () => server.close(), state })); + }); + return Effect.sync(() => { + if (server.listening) server.close(); }); }); @@ -299,10 +312,8 @@ const fakeBatchServer = ( * `@effect/sql-pg`'s `SqlError` wrapping → `legacyToExecError` — through real * server-side statement failures. */ -const fakeQueryServer = ( - onQuery: (sql: string) => Buffer, -): Promise<{ readonly port: number; readonly close: () => void }> => - new Promise((resolve) => { +const fakeQueryServer = (onQuery: (sql: string) => Buffer) => + Effect.callback<{ readonly port: number; readonly close: () => void }, Error>((resume) => { const server = net.createServer((socket) => { let sawStartup = false; let pending = Buffer.alloc(0); @@ -342,7 +353,10 @@ const fakeQueryServer = ( }); server.listen(0, "127.0.0.1", () => { const address = server.address() as net.AddressInfo; - resolve({ port: address.port, close: () => server.close() }); + resume(Effect.succeed({ port: address.port, close: () => server.close() })); + }); + return Effect.sync(() => { + if (server.listening) server.close(); }); }); @@ -351,7 +365,7 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { "surfaces host, user, database, and the driver cause when a remote (--linked) connection is refused", () => Effect.gen(function* () { - const port = yield* Effect.promise(acquireClosedPort); + const port = yield* acquireClosedPort; const error = yield* connectFailure({ port }, false); expect(error._tag).toBe("LegacyDbConnectError"); expect(error.message).toBe( @@ -368,7 +382,7 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { it.live("surfaces the local-stack hint when a local connection is refused", () => Effect.gen(function* () { - const port = yield* Effect.promise(acquireClosedPort); + const port = yield* acquireClosedPort; const error = yield* connectFailure({ port }); expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); expect(error.retryable).toBe(true); @@ -379,22 +393,20 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { "reproduces pgconn's server-error rendering for an auth failure and suggests SUPABASE_DB_PASSWORD", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => - fakePostgresServer((socket) => { - socket.write( - errorResponse({ - S: "FATAL", - V: "FATAL", - C: "28P01", - M: 'password authentication failed for user "postgres"', - F: "auth.c", - L: "326", - R: "auth_failed", - }), - ); - socket.end(); - }), - ); + const server = yield* fakePostgresServer((socket) => { + socket.write( + errorResponse({ + S: "FATAL", + V: "FATAL", + C: "28P01", + M: 'password authentication failed for user "postgres"', + F: "auth.c", + L: "326", + R: "auth_failed", + }), + ); + socket.end(); + }); const error = yield* connectFailure({ port: server.port }).pipe( Effect.ensuring(Effect.sync(server.close)), ); @@ -416,9 +428,7 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { // node-postgres' `Connection terminated unexpectedly`. Go has no // suggestion branch for the equivalent `unexpected EOF`, so no // suggestion may fire — the generic --debug fallback applies. - const server = yield* Effect.promise(() => - fakePostgresServer((socket) => socket.destroy()), - ); + const server = yield* fakePostgresServer((socket) => socket.destroy()); const error = yield* connectFailure({ port: server.port, user: "postgres.abcdefghijklmnopqrst", @@ -446,32 +456,30 @@ describe("legacyDbConnectionSqlPgLayer exec failures", () => { // migration-apply failures back to the opaque driver text. Effect.gen(function* () { const failing = "CREATE TABLE test (path ltree NOT NULL)"; - const server = yield* Effect.promise(() => - fakeQueryServer((sql) => - sql === failing - ? Buffer.concat([ - // `S` (localized) and `V` (unlocalized) are deliberately distinct: - // Go renders pgconn's `PgError.Severity`, populated from the wire - // `S` field (pgproto3 `error_response.go` maps 'S'→Severity, - // 'V'→SeverityUnlocalized), so a localized server prints e.g. - // `FEHLER: …`. pg-protocol likewise assigns `severity = fields.S` - // (`parser.js` parseErrorMessage); asserting `FEHLER` below fails - // the tripwire if a dependency bump ever renders `V` instead. - errorResponse({ - S: "FEHLER", - V: "ERROR", - C: "42704", - M: 'type "ltree" does not exist', - D: "Detail from the server.", - P: "25", - F: "parse_type.c", - L: "270", - R: "typenameType", - }), - READY_FOR_QUERY, - ]) - : Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY]), - ), + const server = yield* fakeQueryServer((sql) => + sql === failing + ? Buffer.concat([ + // `S` (localized) and `V` (unlocalized) are deliberately distinct: + // Go renders pgconn's `PgError.Severity`, populated from the wire + // `S` field (pgproto3 `error_response.go` maps 'S'→Severity, + // 'V'→SeverityUnlocalized), so a localized server prints e.g. + // `FEHLER: …`. pg-protocol likewise assigns `severity = fields.S` + // (`parser.js` parseErrorMessage); asserting `FEHLER` below fails + // the tripwire if a dependency bump ever renders `V` instead. + errorResponse({ + S: "FEHLER", + V: "ERROR", + C: "42704", + M: 'type "ltree" does not exist', + D: "Detail from the server.", + P: "25", + F: "parse_type.c", + L: "270", + R: "typenameType", + }), + READY_FOR_QUERY, + ]) + : Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY]), ); const error: LegacyDbConnectError | LegacyDbExecError = yield* Effect.gen(function* () { const conn = yield* LegacyDbConnection; @@ -494,7 +502,7 @@ describe("legacyDbConnectionSqlPgLayer exec failures", () => { Effect.orDie, ); }).pipe( - Effect.provide(legacyDbConnectionSqlPgLayer), + Effect.provide(legacyDbConnectionSqlPgLayer.pipe(Layer.provide(BunServices.layer))), Effect.ensuring(Effect.sync(server.close)), ); expect(error._tag).toBe("LegacyDbExecError"); @@ -519,9 +527,9 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { throw new Error(`expected a batch exec failure, got ${error._tag}`); }; - const runWithBatchServer = <A>( - server: Awaited<ReturnType<typeof fakeBatchServer>>, - use: (session: LegacyDbSession) => Effect.Effect<A, unknown>, + const runWithBatchServer = <A, E>( + server: Effect.Success<ReturnType<typeof fakeBatchServer>>, + use: (session: LegacyDbSession) => Effect.Effect<A, E>, ) => Effect.gen(function* () { const conn = yield* LegacyDbConnection; @@ -540,13 +548,13 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { return yield* use(session); }).pipe( Effect.scoped, - Effect.provide(legacyDbConnectionSqlPgLayer), + Effect.provide(legacyDbConnectionSqlPgLayer.pipe(Layer.provide(BunServices.layer))), Effect.ensuring(Effect.sync(server.close)), ); it.live("sends every statement and parameter set before one Sync", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 1 })); + const server = yield* fakeBatchServer({ emptyAt: 1 }); const values = ["plain", 'quote"', "slash\\", "comma,", "{brace}", "line\nbreak", "NULL", ""]; yield* runWithBatchServer(server, (session) => session.execBatch([ @@ -593,7 +601,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { it.live("maps a later parse failure to its statement and keeps its local position", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 1, failAt: 2 })); + const server = yield* fakeBatchServer({ emptyAt: 1, failAt: 2 }); yield* runWithBatchServer(server, (session) => Effect.gen(function* () { const error = asBatchExecError( @@ -614,7 +622,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { it.live("maps a position-less runtime failure from completed commands", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ failExecuteAt: 1 })); + const server = yield* fakeBatchServer({ failExecuteAt: 1 }); yield* runWithBatchServer(server, (session) => session .execBatch([{ sql: "SELECT 1" }, { sql: "INSERT duplicate" }, { sql: "SELECT 3" }]) @@ -636,7 +644,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { it.live("reports a deferred Sync failure after every completed statement", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ failOnSync: true })); + const server = yield* fakeBatchServer({ failOnSync: true }); yield* runWithBatchServer(server, (session) => session.execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }]).pipe( Effect.flip, @@ -658,7 +666,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { // would drop the connect suggestion and make the migration-apply formatter blame // the migration's first statement for the database being unreachable. Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ stall: true })); + const server = yield* fakeBatchServer({ stall: true }); const error = yield* runWithBatchServer(server, (session) => Effect.gen(function* () { // Interrupting a batch discards its pooled connection, so the next batch has @@ -678,15 +686,15 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { ); expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); } - }), + }).pipe(Effect.provide(BunServices.layer)), ); }); describe("legacyAcquirePgPool", () => { it.live("returns the winning raw pool and ends it when the caller scope closes", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => - fakeQueryServer(() => Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY])), + const server = yield* fakeQueryServer(() => + Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY]), ); yield* Effect.gen(function* () { let acquired: import("pg").Pool | undefined; @@ -712,6 +720,6 @@ describe("legacyAcquirePgPool", () => { expect(acquired?.ending).toBe(true); expect(acquired?.ended).toBe(true); }).pipe(Effect.ensuring(Effect.sync(server.close))); - }), + }).pipe(Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index d938298368..fccd526680 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -1,8 +1,7 @@ -import { readFileSync } from "node:fs"; import * as net from "node:net"; import type { ConnectionOptions } from "node:tls"; import { PgClient } from "@effect/sql-pg"; -import { Cause, Duration, Effect, Exit, Layer, Scope } from "effect"; +import { Cause, Duration, Effect, Exit, FileSystem, Layer, Scope } from "effect"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; // `pg` is also `@effect/sql-pg`'s transitive driver; we depend on it directly for @@ -22,6 +21,7 @@ import { LegacyDbCopyError, LegacyDbExecError, } from "./legacy-db-connection.errors.ts"; +import { legacyErrorMessage } from "./legacy-error-message.ts"; import { type LegacyDbBatchStatement, type LegacyDbBatchValue, @@ -687,8 +687,17 @@ const legacyToConnectError = ( const acquirePgPoolConnection = ( cfg: LegacyPgConnInput, { isLocal, dnsResolver }: LegacyDbConnectOptions, -) => +): Effect.Effect< + { + readonly pool: Pg.Pool; + readonly winningRawConfig: Pg.ClientConfig; + readonly stepDownRequired: boolean; + }, + LegacyDbConnectError, + Scope.Scope | FileSystem.FileSystem +> => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; // pgconn dials the primary host then each HA fallback in order; // `cfg.fallbacks` carries the extras parsed from a // libpq multi-host connection string. Go installs the Cloudflare DoH resolver @@ -769,13 +778,14 @@ const acquirePgPoolConnection = ( const anyTcpTarget = dialTargets.some(({ dialHost }) => !legacyIsUnixSocketHost(dialHost)); const caCert = rootcertPath !== undefined && rootcertPath.length > 0 && !isLocal && anyTcpTarget - ? yield* Effect.try({ - try: () => readFileSync(rootcertPath, "utf8"), - catch: (error) => - new LegacyDbConnectError({ - message: `failed to read sslrootcert ${rootcertPath}: ${error}`, - }), - }) + ? yield* fs.readFileString(rootcertPath).pipe( + Effect.mapError( + (error) => + new LegacyDbConnectError({ + message: `failed to read sslrootcert ${rootcertPath}: ${legacyErrorMessage(error)}`, + }), + ), + ) : undefined; // Load the client `sslcert`/`sslkey` (pgconn's `configTLS` reads both into @@ -788,20 +798,22 @@ const acquirePgPoolConnection = ( const clientCert = certPath !== undefined && keyPath !== undefined && !isLocal && anyTcpTarget ? { - cert: yield* Effect.try({ - try: () => readFileSync(certPath, "utf8"), - catch: (error) => - new LegacyDbConnectError({ - message: `failed to read sslcert ${certPath}: ${error}`, - }), - }), - key: yield* Effect.try({ - try: () => readFileSync(keyPath, "utf8"), - catch: (error) => - new LegacyDbConnectError({ - message: `failed to read sslkey ${keyPath}: ${error}`, - }), - }), + cert: yield* fs.readFileString(certPath).pipe( + Effect.mapError( + (error) => + new LegacyDbConnectError({ + message: `failed to read sslcert ${certPath}: ${legacyErrorMessage(error)}`, + }), + ), + ), + key: yield* fs.readFileString(keyPath).pipe( + Effect.mapError( + (error) => + new LegacyDbConnectError({ + message: `failed to read sslkey ${keyPath}: ${legacyErrorMessage(error)}`, + }), + ), + ), ...(cfg.sslpassword !== undefined ? { passphrase: cfg.sslpassword } : {}), } : undefined; @@ -881,7 +893,9 @@ const acquirePgPoolConnection = ( yield* Effect.tryPromise({ try: () => pool.query(SET_SESSION_ROLE), catch: (error) => - new LegacyDbConnectError({ message: `failed to set session role: ${error}` }), + new LegacyDbConnectError({ + message: `failed to set session role: ${legacyErrorMessage(error)}`, + }), }); } @@ -897,7 +911,7 @@ const acquirePgPoolConnection = ( export const legacyAcquirePgPool = ( cfg: LegacyPgConnInput, options: LegacyDbConnectOptions, -): Effect.Effect<Pg.Pool, LegacyDbConnectError, Scope.Scope> => +): Effect.Effect<Pg.Pool, LegacyDbConnectError, Scope.Scope | FileSystem.FileSystem> => acquirePgPoolConnection(cfg, options).pipe(Effect.map(({ pool }) => pool)); /** @@ -908,7 +922,7 @@ export const legacyAcquirePgPool = ( const connect = ( cfg: LegacyPgConnInput, options: LegacyDbConnectOptions, -): Effect.Effect<LegacyDbSession, LegacyDbConnectError, Scope.Scope> => +): Effect.Effect<LegacyDbSession, LegacyDbConnectError, Scope.Scope | FileSystem.FileSystem> => Effect.gen(function* () { const { pool, winningRawConfig, stepDownRequired } = yield* acquirePgPoolConnection( cfg, @@ -954,7 +968,9 @@ const connect = ( yield* Effect.tryPromise({ try: () => fresh.query(SET_SESSION_ROLE), catch: (error) => - new LegacyDbConnectError({ message: `failed to set session role: ${error}` }), + new LegacyDbConnectError({ + message: `failed to set session role: ${legacyErrorMessage(error)}`, + }), }); } rawClient = fresh; @@ -1100,7 +1116,9 @@ const connect = ( types: legacyQueryRawTypes, }), catch: (error) => - new LegacyDbExecError({ message: `failed to execute query: ${error}` }), + new LegacyDbExecError({ + message: `failed to execute query: ${legacyErrorMessage(error)}`, + }), }).pipe( Effect.ensuring( Effect.sync(() => @@ -1136,4 +1154,17 @@ const connect = ( return session; }); -export const legacyDbConnectionSqlPgLayer = Layer.succeed(LegacyDbConnection, { connect }); +export const legacyDbConnectionSqlPgLayer = Layer.effect( + LegacyDbConnection, + Effect.gen(function* () { + // DNS-over-HTTPS host resolution is part of the connection implementation, + // but callers should only carry the scoped connection requirement. Capture + // the platform filesystem at the owning layer boundary and provide it to + // each connection effect rather than widening LegacyDbConnection.connect. + const fs = yield* FileSystem.FileSystem; + return { + connect: (cfg: LegacyPgConnInput, options: LegacyDbConnectOptions) => + connect(cfg, options).pipe(Effect.provideService(FileSystem.FileSystem, fs)), + }; + }), +); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index 4d24a158db..ec1c388172 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -1,7 +1,8 @@ import { EventEmitter } from "node:events"; -import { Effect, Exit } from "effect"; +import { Effect, Exit, Fiber } from "effect"; import { SqlError, SqlSyntaxError, UnknownError } from "effect/unstable/sql/SqlError"; -import { describe, expect, it } from "vitest"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect, it } from "@effect/vitest"; import { legacyAcquireProbedPool, @@ -364,33 +365,41 @@ describe("legacyBuildPoolConfig", () => { }); describe("legacyPoolStepDownVerify", () => { - it("runs SET SESSION ROLE postgres and reports success to the pool", async () => { - const queries: Array<string> = []; - const client = { query: (sql: string) => (queries.push(sql), Promise.resolve()) }; - const done = await new Promise<Error | undefined>((resolve) => { - legacyPoolStepDownVerify(client, resolve); + const runVerify = (client: { query: (sql: string) => Promise<void> }) => + Effect.callback<Error | undefined>((resume) => { + legacyPoolStepDownVerify(client, (error) => resume(Effect.succeed(error))); + return Effect.void; }); - expect(queries).toEqual(["SET SESSION ROLE postgres"]); - expect(done).toBeUndefined(); - }); - - it("propagates a failing step-down to the pool callback so the checkout fails (Go AfterConnect parity)", async () => { - const failure = new Error("permission denied to set role"); - const client = { query: () => Promise.reject(failure) }; - const done = await new Promise<Error | undefined>((resolve) => { - legacyPoolStepDownVerify(client, resolve); - }); - expect(done).toBe(failure); - }); - it("wraps a non-Error rejection into an Error for the pool callback", async () => { - const client = { query: () => Promise.reject("boom") }; - const done = await new Promise<Error | undefined>((resolve) => { - legacyPoolStepDownVerify(client, resolve); - }); - expect(done).toBeInstanceOf(Error); - expect(String(done)).toContain("boom"); - }); + it.effect("runs SET SESSION ROLE postgres and reports success to the pool", () => + Effect.gen(function* () { + const queries: Array<string> = []; + const client = { query: (sql: string) => (queries.push(sql), Promise.resolve()) }; + const done = yield* runVerify(client); + expect(queries).toEqual(["SET SESSION ROLE postgres"]); + expect(done).toBeUndefined(); + }), + ); + + it.effect( + "propagates a failing step-down to the pool callback so the checkout fails (Go AfterConnect parity)", + () => + Effect.gen(function* () { + const failure = new Error("permission denied to set role"); + const client = { query: () => Promise.reject(failure) }; + const done = yield* runVerify(client); + expect(done).toBe(failure); + }), + ); + + it.effect("wraps a non-Error rejection into an Error for the pool callback", () => + Effect.gen(function* () { + const client = { query: () => Promise.reject("boom") }; + const done = yield* runVerify(client); + expect(done).toBeInstanceOf(Error); + expect(String(done)).toContain("boom"); + }), + ); }); describe("legacyInstallPoolErrorSwallow", () => { @@ -421,42 +430,52 @@ describe("legacyAcquireProbedPool", () => { return { pool, calls }; } - it("ends the pool when the connect probe rejects", async () => { - const fake = makeFakePool(() => Promise.reject(new Error("ECONNREFUSED"))); - const exit = await Effect.runPromiseExit( - legacyAcquireProbedPool(() => fake.pool, 2).pipe(Effect.scoped), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(fake.calls.query).toBe(1); - // The finalizer, installed the moment the pool exists, closes it on failure. - expect(fake.calls.end).toBe(1); - }); - - it("ends the pool when the connect probe times out (black-holed host)", async () => { - // A never-resolving probe models a black-holed host: `timeoutOrElse` fires and - // the already-installed finalizer still closes the pool + its in-flight dial. - const fake = makeFakePool(() => new Promise<unknown>(() => {})); - const exit = await Effect.runPromiseExit( - legacyAcquireProbedPool(() => fake.pool, 0.05).pipe(Effect.scoped), - ); - expect(Exit.isFailure(exit)).toBe(true); - expect(fake.calls.end).toBe(1); - }); - - it("keeps the pool open until the scope closes on a successful probe", async () => { - const fake = makeFakePool(() => Promise.resolve({ rows: [{ "?column?": 1 }] })); - const observed = await Effect.runPromise( - Effect.gen(function* () { + it.effect("ends the pool when the connect probe rejects", () => + Effect.gen(function* () { + const fake = makeFakePool(() => Promise.reject(new Error("ECONNREFUSED"))); + const exit = yield* legacyAcquireProbedPool(() => fake.pool, 2).pipe( + Effect.scoped, + Effect.exit, + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(fake.calls.query).toBe(1); + // The finalizer, installed the moment the pool exists, closes it on failure. + expect(fake.calls.end).toBe(1); + }), + ); + + it.effect("ends the pool when the connect probe times out (black-holed host)", () => + Effect.gen(function* () { + // A never-resolving probe models a black-holed host: `timeoutOrElse` fires and + // the already-installed finalizer still closes the pool + its in-flight dial. + const never = Promise.race([]); + const fake = makeFakePool(() => never); + const fiber = yield* legacyAcquireProbedPool(() => fake.pool, 0.05).pipe( + Effect.scoped, + Effect.exit, + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("50 millis"); + const exit = yield* Fiber.join(fiber); + expect(Exit.isFailure(exit)).toBe(true); + expect(fake.calls.end).toBe(1); + }), + ); + + it.effect("keeps the pool open until the scope closes on a successful probe", () => + Effect.gen(function* () { + const fake = makeFakePool(() => Promise.resolve({ rows: [{ "?column?": 1 }] })); + const observed = yield* Effect.gen(function* () { const pool = yield* legacyAcquireProbedPool(() => fake.pool, 2); // While the scope is open the pool is live and not yet ended. return { isSamePool: pool === fake.pool, endWhileOpen: fake.calls.end }; - }).pipe(Effect.scoped), - ); - expect(observed.isSamePool).toBe(true); - expect(observed.endWhileOpen).toBe(0); - // Closing the scope ends the pool exactly once. - expect(fake.calls.end).toBe(1); - }); + }).pipe(Effect.scoped); + expect(observed.isSamePool).toBe(true); + expect(observed.endWhileOpen).toBe(0); + // Closing the scope ends the pool exactly once. + expect(fake.calls.end).toBe(1); + }), + ); }); describe("legacyIsUnixSocketHost", () => { diff --git a/apps/cli/src/legacy/shared/legacy-db-dns.ts b/apps/cli/src/legacy/shared/legacy-db-dns.ts index 44a9e353b2..67cac23c2a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-dns.ts +++ b/apps/cli/src/legacy/shared/legacy-db-dns.ts @@ -1,5 +1,6 @@ import * as net from "node:net"; -import { Duration, Effect } from "effect"; +import { Duration, Effect, Schema } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { LegacyDbConnectError } from "./legacy-db-connection.errors.ts"; @@ -10,6 +11,14 @@ const TYPE_A = 1; // IPv4 const TYPE_AAAA = 28; // IPv6 const DOH_TIMEOUT = Duration.seconds(10); +const DnsAnswerSchema = Schema.Struct({ + type: Schema.Finite, + data: Schema.String, +}); +const DnsResponseSchema = Schema.Struct({ + Answer: Schema.optional(Schema.Array(DnsAnswerSchema)), +}); + function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } @@ -60,24 +69,56 @@ export function legacyResolveHostsOverHttps( host: string, ): Effect.Effect<string[], LegacyDbConnectError> { if (net.isIP(host) !== 0) return Effect.succeed([host]); - return Effect.tryPromise({ - try: (signal) => - fetch(`${CF_DOH_URL}?name=${encodeURIComponent(host)}`, { - headers: { accept: "application/dns-json" }, - signal, - }).then(async (response) => { - if (response.status !== 200) { - throw new Error(`unexpected DNS query status ${response.status}`); - } - return parseResolvedIps(await response.json(), host); - }), - catch: (cause) => - new LegacyDbConnectError({ - message: `failed to resolve ${host} via DNS-over-HTTPS: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - }), + return Effect.gen(function* () { + const fetch = yield* FetchHttpClient.Fetch; + const { response, payload } = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${CF_DOH_URL}?name=${encodeURIComponent(host)}`, { + headers: { accept: "application/dns-json" }, + signal, + }).then((response) => + response.status === 200 + ? response.json().then((payload) => ({ response, payload })) + : { response, payload: undefined }, + ), + catch: (cause) => + new LegacyDbConnectError({ + message: `failed to resolve ${host} via DNS-over-HTTPS: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); + if (response.status !== 200) { + return yield* new LegacyDbConnectError({ + message: `failed to resolve ${host} via DNS-over-HTTPS: unexpected DNS query status ${response.status}`, + }); + } + const decoded = yield* Schema.decodeUnknownEffect(DnsResponseSchema)(payload).pipe( + Effect.mapError( + (cause) => + new LegacyDbConnectError({ + message: `failed to resolve ${host} via DNS-over-HTTPS: invalid response: ${String(cause)}`, + }), + ), + ); + return yield* Effect.try({ + try: () => parseResolvedIps(decoded, host), + catch: (cause) => + new LegacyDbConnectError({ + message: `failed to resolve ${host} via DNS-over-HTTPS: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); }).pipe( + Effect.provide(FetchHttpClient.layer), + Effect.mapError((cause) => + cause instanceof LegacyDbConnectError + ? cause + : new LegacyDbConnectError({ + message: `failed to resolve ${host} via DNS-over-HTTPS: ${String(cause)}`, + }), + ), Effect.timeoutOrElse({ duration: DOH_TIMEOUT, orElse: () => diff --git a/apps/cli/src/legacy/shared/legacy-db-dns.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-dns.unit.test.ts index 822b4a29a3..99bc678d87 100644 --- a/apps/cli/src/legacy/shared/legacy-db-dns.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-dns.unit.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as TestClock from "effect/testing/TestClock"; -import { parseResolvedIps } from "./legacy-db-dns.ts"; +import { legacyResolveHostsOverHttps, parseResolvedIps } from "./legacy-db-dns.ts"; describe("parseResolvedIps", () => { it("returns every A/AAAA address in order, skipping non-address records", () => { @@ -48,3 +51,35 @@ describe("parseResolvedIps", () => { expect(() => parseResolvedIps(null, "db.example.com")).toThrow("failed to locate valid IP"); }); }); + +describe("legacyResolveHostsOverHttps", () => { + it.effect("aborts the DNS-over-HTTPS body read when its deadline expires", () => + Effect.gen(function* () { + let requestSignal: AbortSignal | undefined; + const fetchFn = Object.assign( + ( + _input: Parameters<typeof globalThis.fetch>[0], + init?: Parameters<typeof globalThis.fetch>[1], + ) => { + requestSignal = init?.signal ?? undefined; + const response = new Response("{}", { status: 200 }); + Object.defineProperty(response, "json", { + value: () => Promise.race([]), + }); + return Promise.resolve(response); + }, + { preconnect: globalThis.fetch.preconnect }, + ); + const fiber = yield* legacyResolveHostsOverHttps("db.example.com").pipe( + Effect.provide(Layer.succeed(FetchHttpClient.Fetch, fetchFn)), + Effect.forkChild({ startImmediately: true }), + ); + + yield* TestClock.adjust("11 seconds"); + + expect(fiber.pollUnsafe()).toBeDefined(); + expect(requestSignal).toBeDefined(); + expect(requestSignal?.aborted).toBe(true); + }), + ); +}); diff --git a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts index f74184da96..ab552a0e82 100644 --- a/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-image.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; @@ -8,8 +5,6 @@ import { Effect, FileSystem, Path } from "effect"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; import { legacyResolveDbImage } from "./legacy-db-image.ts"; -const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-db-image-")); - const resolve = (workdir: string, majorVersion: number, orioledbVersion?: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -19,32 +14,35 @@ const resolve = (workdir: string, majorVersion: number, orioledbVersion?: string describe("legacyResolveDbImage", () => { it.effect("resolves the default Postgres image per major version", () => { - const dir = withTemp(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-db-image-" }); expect(yield* resolve(dir, 14)).toBe("supabase/postgres:14.1.0.89"); expect(yield* resolve(dir, 15)).toBe("supabase/postgres:15.8.1.085"); expect(yield* resolve(dir, 17)).toBe(dockerfileServiceImage("pg")); - rmSync(dir, { recursive: true, force: true }); - }); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("rewrites to the OrioleDB image on a 15/17 project (Go config.Validate)", () => { - const dir = withTemp(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-db-image-" }); // > 15.1.1.13 → `<ver>-orioledb` expect(yield* resolve(dir, 17, "16.0.0.1")).toBe("supabase/postgres:16.0.0.1-orioledb"); expect(yield* resolve(dir, 15, "15.1.1.20")).toBe("supabase/postgres:15.1.1.20-orioledb"); // <= 15.1.1.13 → `orioledb-<ver>` expect(yield* resolve(dir, 17, "15.1.0.55")).toBe("supabase/postgres:orioledb-15.1.0.55"); - rmSync(dir, { recursive: true, force: true }); - }); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("ignores orioledb_version on a non-15/17 project", () => { - const dir = withTemp(); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-db-image-" }); expect(yield* resolve(dir, 14, "16.0.0.1")).toBe("supabase/postgres:14.1.0.89"); - rmSync(dir, { recursive: true, force: true }); - }); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-push-core.ts b/apps/cli/src/legacy/shared/legacy-db-push-core.ts index 24ee15d27a..8042c19e26 100644 --- a/apps/cli/src/legacy/shared/legacy-db-push-core.ts +++ b/apps/cli/src/legacy/shared/legacy-db-push-core.ts @@ -205,23 +205,19 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush const local = yield* legacyListLocalMigrations(fs, path, migrationsDir); const result = legacyFindPendingMigrations(local, remote); if (result.kind === "missing-local") { - return yield* Effect.fail( - new LegacyDbPushMissingLocalError({ - message: LEGACY_ERR_MISSING_LOCAL, - suggestion: legacySuggestRevertHistory(result.versions, repairSuggestsLocalFlag), - }), - ); + return yield* new LegacyDbPushMissingLocalError({ + message: LEGACY_ERR_MISSING_LOCAL, + suggestion: legacySuggestRevertHistory(result.versions, repairSuggestsLocalFlag), + }); } if (result.kind === "missing-remote") { if (!includeAll) { // Go's suggestIgnoreFlag lists the workdir-relative paths. const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); - return yield* Effect.fail( - new LegacyDbPushMissingRemoteError({ - message: LEGACY_ERR_MISSING_REMOTE, - suggestion: legacySuggestIgnoreFlag(relPaths), - }), - ); + return yield* new LegacyDbPushMissingRemoteError({ + message: LEGACY_ERR_MISSING_REMOTE, + suggestion: legacySuggestIgnoreFlag(relPaths), + }); } pending = legacyIncludeAllPending(local, remote.length, result.paths); } else { @@ -294,9 +290,7 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush true, ); if (!ok) { - return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } yield* legacySeedGlobals( session, @@ -316,9 +310,7 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush true, ); if (!ok) { - return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } if (includeVault) { yield* legacyUpsertVaultSecrets(session, vaultSecrets); @@ -329,7 +321,7 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush legacyParseBoolEnv(toml.envLookup("SUPABASE_EXPERIMENTAL_PG_DELTA")); const pgDeltaImplementation = legacyResolvePgDeltaImplementation( legacyPgDeltaImplementationFlag( - process.env[LEGACY_PG_DELTA_NEXT_FLAG_NAME], + toml.envLookup(LEGACY_PG_DELTA_NEXT_FLAG_NAME), toml.projectEnv[LEGACY_PG_DELTA_NEXT_FLAG_NAME], ), ); @@ -386,11 +378,9 @@ export const legacyDbPushCore = Effect.fnUntraced(function* (input: LegacyDbPush true, ); if (!ok) { - return yield* Effect.fail( - new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new LegacyDbPushCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } - yield* legacySeedData(session, fs, workdir, path, seeds, applyError); + yield* legacySeedData(session, fs, workdir, path, seeds, toml.projectEnv, applyError); } else if (includeSeed) { yield* output.raw("Seed files are up to date.\n", "stderr"); } diff --git a/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts index d8d9699466..ade8f80893 100644 --- a/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-target-flags.unit.test.ts @@ -1,7 +1,7 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path, PlatformError } from "effect"; import { resolveLegacyDbTargetFlags, VALUE_CONSUMING_LONG_FLAGS, @@ -213,14 +213,27 @@ describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness const INDIRECT_NAME_FILES = new Set(["issue.command.ts"]); const VALUE_FLAG_KINDS = ["string", "integer", "choice", "choiceWithValue", "float"]; - function walk(dir: string): Array<string> { - return readdirSync(dir).flatMap((entry) => { - const fullPath = path.join(dir, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) return walk(fullPath); - return entry.endsWith(".command.ts") ? [fullPath] : []; + const walk = ( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, + ): Effect.Effect<Array<string>, PlatformError.PlatformError> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(dir); + const nested = yield* Effect.forEach(entries, (entry) => { + const fullPath = path.join(dir, entry); + return fs + .stat(fullPath) + .pipe( + Effect.flatMap((stats) => + stats.type === "Directory" + ? walk(fs, path, fullPath) + : Effect.succeed(entry.endsWith(".command.ts") ? [fullPath] : []), + ), + ); + }); + return nested.flat(); }); - } interface DeclaredFlag { readonly file: string; @@ -228,66 +241,85 @@ describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness readonly alias: string | undefined; } - function extractDeclaredFlags(filePath: string): Array<DeclaredFlag> { - const source = readFileSync(filePath, "utf8"); - const callRegex = /Flag\.(string|integer|choice|choiceWithValue|float|boolean)\(/g; - const calls = Array.from(source.matchAll(callRegex), (match) => ({ - index: match.index, - kind: match[1]!, - })); - - const declared: Array<DeclaredFlag> = []; - for (let i = 0; i < calls.length; i++) { - const current = calls[i]!; - if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; - - // Name declared as a literal string (e.g. `Flag.string("schema")`). - // A name passed as an identifier (`Flag.string(name)`) doesn't match - // and is silently skipped — see INDIRECT_NAME_FILES above. - const remainder = source.slice(current.index); - const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); - if (!nameMatch) continue; - - // The alias, if any, is somewhere in the `.pipe(...)` chain between - // this flag declaration and the next one. - const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; - const window = source.slice(current.index, windowEnd); - const aliasMatch = window.match(/withAlias\(\s*"([a-zA-Z0-9])"\s*\)/); - - declared.push({ file: filePath, name: nameMatch[1]!, alias: aliasMatch?.[1] }); - } - return declared; - } - - it("registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", () => { - const missing: Array<string> = []; - - for (const filePath of walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - - for (const flag of extractDeclaredFlags(filePath)) { - if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { - missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); + const extractDeclaredFlags = ( + fs: FileSystem.FileSystem, + filePath: string, + ): Effect.Effect<Array<DeclaredFlag>, PlatformError.PlatformError> => + fs.readFileString(filePath).pipe( + Effect.map((source) => { + const callRegex = /Flag\.(string|integer|choice|choiceWithValue|float|boolean)\(/g; + const calls = Array.from(source.matchAll(callRegex), (match) => ({ + index: match.index, + kind: match[1]!, + })); + + const declared: Array<DeclaredFlag> = []; + for (let i = 0; i < calls.length; i++) { + const current = calls[i]!; + if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; + + // Name declared as a literal string (e.g. `Flag.string("schema")`). + // A name passed as an identifier (`Flag.string(name)`) doesn't match + // and is silently skipped — see INDIRECT_NAME_FILES above. + const remainder = source.slice(current.index); + const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); + if (!nameMatch) continue; + + // The alias, if any, is somewhere in the `.pipe(...)` chain between + // this flag declaration and the next one. + const windowEnd = i + 1 < calls.length ? calls[i + 1]!.index : source.length; + const window = source.slice(current.index, windowEnd); + const aliasMatch = window.match(/withAlias\(\s*"([a-zA-Z0-9])"\s*\)/); + + declared.push({ file: filePath, name: nameMatch[1]!, alias: aliasMatch?.[1] }); + } + return declared; + }), + ); + + it.effect( + "registers every directly-declared value-consuming flag name in VALUE_CONSUMING_LONG_FLAGS", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const missing: Array<string> = []; + + for (const filePath of yield* walk(fs, path, commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of yield* extractDeclaredFlags(fs, filePath)) { + if (!VALUE_CONSUMING_LONG_FLAGS.has(flag.name)) { + missing.push(`${flag.name} (${path.relative(commandsDir, flag.file)})`); + } + } } - } - } - - expect(missing).toEqual([]); - }); - - it("registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", () => { - const missing: Array<string> = []; - - for (const filePath of walk(commandsDir)) { - if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; - for (const flag of extractDeclaredFlags(filePath)) { - if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { - missing.push(`-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`); + expect(missing).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "registers every directly-declared value-consuming flag's shorthand in VALUE_CONSUMING_SHORT_FLAGS", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const missing: Array<string> = []; + + for (const filePath of yield* walk(fs, path, commandsDir)) { + if (INDIRECT_NAME_FILES.has(path.basename(filePath))) continue; + + for (const flag of yield* extractDeclaredFlags(fs, filePath)) { + if (flag.alias !== undefined && !VALUE_CONSUMING_SHORT_FLAGS.has(flag.alias)) { + missing.push( + `-${flag.alias} (--${flag.name}, ${path.relative(commandsDir, flag.file)})`, + ); + } + } } - } - } - expect(missing).toEqual([]); - }); + expect(missing).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts index 3658df0e70..a5ad4abf74 100644 --- a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect"; +import { Clock, DateTime, Effect, Layer } from "effect"; import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; @@ -6,10 +6,11 @@ import { LegacyDebugLogger } from "./legacy-debug-logger.service.ts"; const pad = (n: number): string => String(n).padStart(2, "0"); /** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ -function formatTimestamp(now: Date): string { +function formatTimestamp(now: DateTime.DateTime): string { + const parts = DateTime.toParts(now); return ( - `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + - `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}` + `${parts.year}/${pad(parts.month)}/${pad(parts.day)} ` + + `${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}` ); } @@ -23,9 +24,15 @@ export const legacyDebugLoggerLayer = Layer.effect( if (debug) process.stderr.write(`${message}\n`); }); + const timestamp = (method: string, url: string) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + return `${formatTimestamp(DateTime.makeZonedUnsafe(now, { timeZone: DateTime.zoneMakeLocal() }))} HTTP ${method}: ${url}`; + }); + return LegacyDebugLogger.of({ debug: writeLine, - http: (method, url) => writeLine(`${formatTimestamp(new Date())} HTTP ${method}: ${url}`), + http: (method, url) => timestamp(method, url).pipe(Effect.flatMap(writeLine)), }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts index 5886d2dd3b..6fb37c5e6d 100644 --- a/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-debug-logger.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { afterEach, vi } from "vitest"; +import { DateTime, Effect, Layer } from "effect"; +import { TestClock } from "effect/testing"; +import { vi } from "vitest"; import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; @@ -14,10 +15,6 @@ function captureStderr() { return vi.spyOn(process.stderr, "write").mockImplementation(() => true); } -afterEach(() => { - vi.useRealTimers(); -}); - describe("legacyDebugLoggerLayer", () => { it.effect("does not write stderr bytes when debug is disabled", () => { const stderr = captureStderr(); @@ -47,10 +44,13 @@ describe("legacyDebugLoggerLayer", () => { }); it.effect("http emits Go timestamp order and method/url format", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date(2026, 5, 4, 8, 24, 47)); const stderr = captureStderr(); return Effect.gen(function* () { + const localTime = DateTime.makeZonedUnsafe( + { year: 2026, month: 6, day: 4, hour: 8, minute: 24, second: 47, millisecond: 0 }, + { timeZone: DateTime.zoneMakeLocal(), adjustForTimeZone: true }, + ); + yield* TestClock.setTime(DateTime.toEpochMillis(localTime)); const logger = yield* LegacyDebugLogger; yield* logger.http("GET", "https://api.supabase.green/v1/projects"); expect(stderr.mock.calls.map(([chunk]) => String(chunk)).join("")).toBe( diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.ts index 8b34ad1961..21a26f68a9 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.ts @@ -6,8 +6,6 @@ * whether the local stack is running. */ -import { basename } from "node:path"; - /** * Resolve the project id Go feeds into `utils.DbId`/`utils.NetId`. viper sets * `Config.ProjectId` from config.toml's `project_id`, then `AutomaticEnv` overrides it @@ -29,7 +27,9 @@ export function legacyResolveLocalProjectId( if (envProjectId !== undefined && envProjectId.length > 0) return envProjectId; if (tomlProjectId !== undefined && tomlProjectId.length > 0) return tomlProjectId; if (projectRefDefault !== undefined && projectRefDefault.length > 0) return projectRefDefault; - return basename(workdir); + const normalized = workdir.replaceAll("\\", "/").replace(/\/+$/, ""); + const lastSeparator = normalized.lastIndexOf("/"); + return lastSeparator >= 0 ? normalized.slice(lastSeparator + 1) : normalized; } const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; diff --git a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts index 416dc82981..e6dd866aa2 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-ids.unit.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; import { LEGACY_CLI_PROJECT_LABEL, @@ -11,6 +12,7 @@ import { } from "./legacy-docker-ids.ts"; import { resolveDockerNetworkMode } from "../../shared/functions/functions-docker.ts"; import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; describe("legacyResolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -87,57 +89,82 @@ describe("legacyCliProjectFilterValue", () => { describe("resolveDockerNetworkMode composed with legacyViperEnvStringWithProjectFallback (start/db start call shape)", () => { const KEY = "SUPABASE_NETWORK_ID"; - afterEach(() => { - delete process.env[KEY]; - }); - // `start`/`db start` resolve the network exactly like the `functions` // Docker paths: the shared 3-way resolver fed by the viper-shaped // shell/project-dotenv env read — one home, per the review round on // CLI-1963 that deleted `legacyResolveNetworkId`'s divergent copy. - function resolve(flagValue: string | undefined, projectEnv: Record<string, string>) { - return resolveDockerNetworkMode({ - explicit: flagValue, - envOverride: legacyViperEnvStringWithProjectFallback(KEY, projectEnv), - projectId: "my-app", - }); + function resolve( + flagValue: string | undefined, + projectEnv: Record<string, string>, + shellEnv: Record<string, string> = {}, + ) { + return Effect.gen(function* () { + return resolveDockerNetworkMode({ + explicit: flagValue, + envOverride: yield* legacyViperEnvStringWithProjectFallback(KEY, projectEnv), + projectId: "my-app", + }); + }).pipe( + Effect.provide( + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: shellEnv, preserveEmptyStrings: true }), + ), + ), + ); } - it("prefers an explicit --network-id flag over everything else", () => { - process.env[KEY] = "env-network"; - expect(resolve("flag-network", { [KEY]: "toml-network" })).toBe("flag-network"); - }); - - it("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => { - process.env[KEY] = "shell-network"; - expect(resolve(undefined, {})).toBe("shell-network"); - }); - - it("falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", () => { - delete process.env[KEY]; - expect(resolve(undefined, { [KEY]: "project-network" })).toBe("project-network"); - }); - - it("prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", () => { - process.env[KEY] = "shell-network"; - expect(resolve(undefined, { [KEY]: "project-network" })).toBe("shell-network"); - }); - - it("falls back to the generated network name when the flag and env are all absent/empty", () => { - delete process.env[KEY]; - expect(resolve(undefined, {})).toBe(localNetworkId("my-app")); - expect(resolve("", {})).toBe(localNetworkId("my-app")); - }); - - it("an explicit-but-empty --network-id= skips the env var entirely (viper: a Changed pflag resolves before AutomaticEnv)", () => { - process.env[KEY] = "env-network"; - expect(resolve("", { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); - }); - - it("treats an empty shell value as present (blocks the project value) and falls to generated", () => { - process.env[KEY] = ""; - expect(resolve(undefined, { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); - }); + it.effect("prefers an explicit --network-id flag over everything else", () => + resolve("flag-network", { [KEY]: "toml-network" }, { [KEY]: "env-network" }).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe("flag-network"))), + ), + ); + + it.effect("falls back to SUPABASE_NETWORK_ID (shell) when the flag is absent", () => + resolve(undefined, {}, { [KEY]: "shell-network" }).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe("shell-network"))), + ), + ); + + it.effect( + "falls back to SUPABASE_NETWORK_ID (project .env) when both the flag and shell are absent", + () => + resolve(undefined, { [KEY]: "project-network" }).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe("project-network"))), + ), + ); + + it.effect( + "prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", + () => + resolve(undefined, { [KEY]: "project-network" }, { [KEY]: "shell-network" }).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe("shell-network"))), + ), + ); + + it.effect( + "falls back to the generated network name when the flag and env are all absent/empty", + () => + Effect.gen(function* () { + expect(yield* resolve(undefined, {})).toBe(localNetworkId("my-app")); + expect(yield* resolve("", {})).toBe(localNetworkId("my-app")); + }), + ); + + it.effect( + "an explicit-but-empty --network-id= skips the env var entirely (viper: a Changed pflag resolves before AutomaticEnv)", + () => + resolve("", { [KEY]: "project-network" }, { [KEY]: "env-network" }).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe(localNetworkId("my-app")))), + ), + ); + + it.effect( + "treats an empty shell value as present (blocks the project value) and falls to generated", + () => + resolve(undefined, { [KEY]: "project-network" }, { [KEY]: "" }).pipe( + Effect.tap((value) => Effect.sync(() => expect(value).toBe(localNetworkId("my-app")))), + ), + ); }); describe("legacySanitizeProjectId", () => { diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts index 31f84beeee..32df871a67 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.ts @@ -1,4 +1,4 @@ -import { Effect, Exit, Stream } from "effect"; +import { Cause, Clock, Effect, Exit, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { spawnContainerCli } from "./legacy-container-cli.ts"; import { LegacyDockerRunError } from "./legacy-docker-run.errors.ts"; @@ -127,13 +127,11 @@ export function legacyMakeDockerImageResolver( if (isImageNotFoundMessage(stderr)) return false; const daemonDown = legacyIsDockerDaemonUnreachable(stderr); const hint = daemonDown ? `\n\n${LEGACY_SUGGEST_DOCKER_INSTALL}` : ""; - return yield* Effect.fail( - new LegacyDockerRunError({ - message: `failed to inspect docker image: ${stderr}${hint}`, - reason: "inspect", - daemonDown, - }), - ); + return yield* new LegacyDockerRunError({ + message: `failed to inspect docker image: ${stderr}${hint}`, + reason: "inspect", + daemonDown, + }); }).pipe(Effect.scoped); const pullImage = ( @@ -149,7 +147,7 @@ export function legacyMakeDockerImageResolver( stderr: "pipe", detached: false, extendEnv: true, - }).pipe(Effect.mapError(() => new Error("spawn"))); + }).pipe(Effect.mapError(() => new Cause.UnknownError(undefined, String("spawn")))); // Tee pull progress to the parent terminal in real time so a large, // uncached pull does not look frozen — Go streams the same progress via // `jsonmessage.DisplayJSONMessagesToStream`. Progress goes to stderr so @@ -195,7 +193,7 @@ export function legacyMakeDockerImageResolver( return (image: string, deadline?: number): Effect.Effect<string, LegacyDockerRunError> => Effect.gen(function* () { - const candidates = legacyGetRegistryImageUrlCandidates(image, projectEnvValues); + const candidates = legacyGetRegistryImageUrlCandidates(image, projectEnvValues ?? {}); for (const candidate of candidates) { if (yield* hasLocalImage(candidate)) { return candidate; @@ -214,11 +212,12 @@ export function legacyMakeDockerImageResolver( let candidateShareMs: number | undefined; let candidateDeadline: number | undefined; if (deadline !== undefined) { + const now = yield* Clock.currentTimeMillis; candidateShareMs = Math.max( 1, - Math.floor((deadline - Date.now()) / (candidates.length - candidateIndex)), + Math.floor((deadline - now) / (candidates.length - candidateIndex)), ); - candidateDeadline = Math.min(Date.now() + candidateShareMs, deadline); + candidateDeadline = Math.min(now + candidateShareMs, deadline); } // Whether the most recent failed attempt's teed output ended with a // newline — read by the retry banner below, which runs outside the @@ -231,7 +230,9 @@ export function legacyMakeDockerImageResolver( ) { const attempt = attemptIndex + 1; const remainingMs = - candidateDeadline === undefined ? undefined : candidateDeadline - Date.now(); + candidateDeadline === undefined + ? undefined + : candidateDeadline - (yield* Clock.currentTimeMillis); if (remainingMs !== undefined && remainingMs <= 0) { failures.push( `${candidate} attempt ${attempt}: candidate budget exhausted (${candidateShareMs}ms share)`, @@ -273,7 +274,7 @@ export function legacyMakeDockerImageResolver( // candidate can fix a missing Docker/Podman binary, so stop here // and surface the install hint instead of an opaque, repeated // spawn error across every candidate. - return yield* Effect.fail(spawnError()); + return yield* spawnError(); } const delay = DOCKER_PULL_RETRY_DELAYS_MS[attemptIndex]; @@ -282,7 +283,10 @@ export function legacyMakeDockerImageResolver( } // Never sleep past this candidate's share — the backoff would spend // budget the remaining registries still need. - if (candidateDeadline !== undefined && Date.now() + delay >= candidateDeadline) { + if ( + candidateDeadline !== undefined && + (yield* Clock.currentTimeMillis) + delay >= candidateDeadline + ) { break; } // Go prints a per-retry banner before sleeping: @@ -305,12 +309,10 @@ export function legacyMakeDockerImageResolver( } } - return yield* Effect.fail( - new LegacyDockerRunError({ - message: `failed to pull docker image from all registries: ${failures.join("; ")}`, - reason: "pull", - daemonDown: failures.some(legacyIsDockerDaemonUnreachable), - }), - ); + return yield* new LegacyDockerRunError({ + message: `failed to pull docker image from all registries: ${failures.join("; ")}`, + reason: "pull", + daemonDown: failures.some(legacyIsDockerDaemonUnreachable), + }); }); } diff --git a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts index d23b77caba..1e4bfb4f62 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-image-resolve.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Sink, Stream } from "effect"; +import { Clock, Deferred, Effect, Fiber, Sink, Stream } from "effect"; import type * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as TestClock from "effect/testing/TestClock"; @@ -115,8 +115,6 @@ describe("legacyMakeDockerImageResolver", () => { // unchanged) so the assertions below cover exactly one candidate's // attempt count, rather than the full ECR/GHCR/Docker Hub fallback // list built by `legacyGetRegistryImageUrlCandidates`. - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; // Records every chunk written to stderr, including the `docker pull` child's own // stdout/stderr, which `pullImage` tees live to the parent's stderr as `Uint8Array` // chunks — only the `Retrying after …` banner (and its fresh-line `"\n"` separator) @@ -140,7 +138,9 @@ describe("legacyMakeDockerImageResolver", () => { { exitCode: 1, stderr: "no space left on device" }, { exitCode: 1, stderr: "no space left on device" }, ]); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); const fiber = yield* resolve("supabase/postgres:17.6.1.138").pipe( Effect.forkChild({ startImmediately: true }), ); @@ -185,8 +185,6 @@ describe("legacyMakeDockerImageResolver", () => { expect(transcript).not.toContain("deviceRetrying"); } finally { globalThis.process.stderr.write = originalWrite; - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; } }), ); @@ -195,8 +193,6 @@ describe("legacyMakeDockerImageResolver", () => { "resolves successfully once a retried pull succeeds, without waiting for the second backoff", () => Effect.gen(function* () { - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; const stderrChunks: Array<unknown> = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); globalThis.process.stderr.write = ((chunk: unknown) => { @@ -209,7 +205,9 @@ describe("legacyMakeDockerImageResolver", () => { { exitCode: 1, stderr: "no space left on device" }, { exitCode: 0 }, ]); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); const fiber = yield* resolve("supabase/postgres:17.6.1.138").pipe( Effect.forkChild({ startImmediately: true }), ); @@ -229,8 +227,6 @@ describe("legacyMakeDockerImageResolver", () => { expect(retryBanners).toEqual(["Retrying after 4s: supabase/postgres:17.6.1.138\n"]); } finally { globalThis.process.stderr.write = originalWrite; - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; } }), ); @@ -239,8 +235,6 @@ describe("legacyMakeDockerImageResolver", () => { "does not inject a blank line before the banner when the child error is newline-terminated", () => Effect.gen(function* () { - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; const stderrChunks: Array<unknown> = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); globalThis.process.stderr.write = ((chunk: unknown) => { @@ -253,7 +247,9 @@ describe("legacyMakeDockerImageResolver", () => { { exitCode: 1, stderr: "no space left on device\n" }, { exitCode: 0 }, ]); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); const fiber = yield* resolve("supabase/postgres:17.6.1.138").pipe( Effect.forkChild({ startImmediately: true }), ); @@ -272,8 +268,6 @@ describe("legacyMakeDockerImageResolver", () => { expect(transcript).not.toContain("\n\nRetrying"); } finally { globalThis.process.stderr.write = originalWrite; - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; } }), ); @@ -289,32 +283,36 @@ describe("legacyMakeDockerImageResolver", () => { { exitCode: 1, stderr: "denied" }, ]); const resolve = legacyMakeDockerImageResolver(mock.spawner); - return resolve("supabase/postgres:15", Date.now() + 500).pipe( - Effect.flip, - Effect.map((error) => { - expect(error).toBeInstanceOf(LegacyDockerRunError); - expect(mock.pulls.length).toBe(3); - expect(new Set(mock.pulls).size).toBe(3); - }), - ); + return Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* resolve("supabase/postgres:15", now + 500).pipe( + Effect.flip, + Effect.map((error) => { + expect(error).toBeInstanceOf(LegacyDockerRunError); + expect(mock.pulls.length).toBe(3); + expect(new Set(mock.pulls).size).toBe(3); + }), + ); + }); }); it.live("reports exhausted candidate budgets instead of pulling past a spent deadline", () => { const mock = mockSpawner([]); const resolve = legacyMakeDockerImageResolver(mock.spawner); - return resolve("supabase/postgres:15", Date.now() - 1_000).pipe( - Effect.flip, - Effect.map((error) => { - expect(error.message).toContain("candidate budget exhausted"); - expect(mock.pulls.length).toBe(0); - }), - ); + return Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* resolve("supabase/postgres:15", now - 1_000).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toContain("candidate budget exhausted"); + expect(mock.pulls.length).toBe(0); + }), + ); + }); }); it.effect("prints no Retrying banner when the first pull attempt succeeds", () => Effect.gen(function* () { - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; const stderrChunks: Array<unknown> = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); globalThis.process.stderr.write = ((chunk: unknown) => { @@ -324,7 +322,9 @@ describe("legacyMakeDockerImageResolver", () => { try { const mock = mockSpawner([{ exitCode: 0 }]); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); const image = yield* resolve("supabase/postgres:17.6.1.138"); @@ -337,33 +337,25 @@ describe("legacyMakeDockerImageResolver", () => { expect(retryBanners).toEqual([]); } finally { globalThis.process.stderr.write = originalWrite; - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; } }), ); it.effect("fails fast on a daemon-unreachable image inspect without ever attempting a pull", () => Effect.gen(function* () { - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; - - try { - const daemonUnreachableStderr = - "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; - const mock = mockSpawner([], { exitCode: 1, stderr: daemonUnreachableStderr }); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + const daemonUnreachableStderr = + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; + const mock = mockSpawner([], { exitCode: 1, stderr: daemonUnreachableStderr }); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); - const error = yield* resolve("supabase/postgres:17.6.1.138").pipe(Effect.flip); + const error = yield* resolve("supabase/postgres:17.6.1.138").pipe(Effect.flip); - expect(error).toBeInstanceOf(LegacyDockerRunError); - expect(error.message).toContain(daemonUnreachableStderr); - expect(error.message).toContain(LEGACY_SUGGEST_DOCKER_INSTALL); - expect(mock.pulls).toHaveLength(0); - } finally { - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; - } + expect(error).toBeInstanceOf(LegacyDockerRunError); + expect(error.message).toContain(daemonUnreachableStderr); + expect(error.message).toContain(LEGACY_SUGGEST_DOCKER_INSTALL); + expect(mock.pulls).toHaveLength(0); }), ); @@ -371,29 +363,23 @@ describe("legacyMakeDockerImageResolver", () => { "fails fast on a non-not-found image inspect error (e.g. an auth-plugin denial) without ever attempting a pull", () => Effect.gen(function* () { - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; - - try { - // `DockerResolveImageIfNotCached` treats ONLY a confirmed `errdefs.IsNotFound` - // as a cache miss; every other inspect error — this is neither a "no such image" nor - // a daemon-unreachable message — returns immediately instead of falling through to - // the pull loop. - const authPluginDenialStderr = - "Error response from daemon: authorization denied by plugin AuthZPlugin: no policy matched"; - const mock = mockSpawner([], { exitCode: 1, stderr: authPluginDenialStderr }); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + // `DockerResolveImageIfNotCached` treats ONLY a confirmed `errdefs.IsNotFound` + // as a cache miss; every other inspect error — this is neither a "no such image" nor + // a daemon-unreachable message — returns immediately instead of falling through to + // the pull loop. + const authPluginDenialStderr = + "Error response from daemon: authorization denied by plugin AuthZPlugin: no policy matched"; + const mock = mockSpawner([], { exitCode: 1, stderr: authPluginDenialStderr }); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); - const error = yield* resolve("supabase/postgres:17.6.1.138").pipe(Effect.flip); + const error = yield* resolve("supabase/postgres:17.6.1.138").pipe(Effect.flip); - expect(error).toBeInstanceOf(LegacyDockerRunError); - expect(error.message).toContain(authPluginDenialStderr); - expect(error.message).not.toContain(LEGACY_SUGGEST_DOCKER_INSTALL); - expect(mock.pulls).toHaveLength(0); - } finally { - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; - } + expect(error).toBeInstanceOf(LegacyDockerRunError); + expect(error.message).toContain(authPluginDenialStderr); + expect(error.message).not.toContain(LEGACY_SUGGEST_DOCKER_INSTALL); + expect(mock.pulls).toHaveLength(0); }), ); @@ -401,27 +387,21 @@ describe("legacyMakeDockerImageResolver", () => { "treats Podman's differently worded image-inspect miss as a cache miss and proceeds to pull", () => Effect.gen(function* () { - const previousRegistry = process.env[REGISTRY_ENV]; - process.env[REGISTRY_ENV] = "docker.io"; - - try { - // An uncached `podman image inspect <missing>` exits non-zero with `image not - // known` rather than Docker's `No such image` — see `isImageNotFoundMessage`'s - // doc comment. Both wordings must reach the pull loop identically. - const mock = mockSpawner([{ exitCode: 0 }], { - exitCode: 1, - stderr: "supabase/postgres:17.6.1.138: image not known", - }); - const resolve = legacyMakeDockerImageResolver(mock.spawner); + // An uncached `podman image inspect <missing>` exits non-zero with `image not + // known` rather than Docker's `No such image` — see `isImageNotFoundMessage`'s + // doc comment. Both wordings must reach the pull loop identically. + const mock = mockSpawner([{ exitCode: 0 }], { + exitCode: 1, + stderr: "supabase/postgres:17.6.1.138: image not known", + }); + const resolve = legacyMakeDockerImageResolver(mock.spawner, { + [REGISTRY_ENV]: "docker.io", + }); - const image = yield* resolve("supabase/postgres:17.6.1.138"); + const image = yield* resolve("supabase/postgres:17.6.1.138"); - expect(image).toBe("supabase/postgres:17.6.1.138"); - expect(mock.pulls).toHaveLength(1); - } finally { - if (previousRegistry === undefined) delete process.env[REGISTRY_ENV]; - else process.env[REGISTRY_ENV] = previousRegistry; - } + expect(image).toBe("supabase/postgres:17.6.1.138"); + expect(mock.pulls).toHaveLength(1); }), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index 77cb13cf4d..ec77d9cf63 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -64,7 +64,7 @@ export class LegacyDockerLifecycleInspectError extends Data.TaggedError( } } -function collectByteStream(stream: Stream.Stream<Uint8Array, unknown>) { +function collectByteStream<E>(stream: Stream.Stream<Uint8Array, E>) { const decoder = new TextDecoder(); return Stream.runFold( stream, @@ -135,14 +135,12 @@ function spawnDockerPsLines( ); if (exitCode !== 0) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyDockerLifecycleListError({ - message: - message.length > 0 - ? `failed to list containers: ${message}` - : "failed to list containers", - }), - ); + return yield* new LegacyDockerLifecycleListError({ + message: + message.length > 0 + ? `failed to list containers: ${message}` + : "failed to list containers", + }); } return splitNonEmptyLines(stdout); }), @@ -268,15 +266,13 @@ export const legacyInspectContainerState = (spawner: Spawner, containerId: strin ); if (exitCode !== 0) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyDockerLifecycleInspectError({ - message: - message.length > 0 - ? `failed to inspect container health: ${message}` - : "failed to inspect container health", - daemonDown: isDockerDaemonDownMessage(message), - }), - ); + return yield* new LegacyDockerLifecycleInspectError({ + message: + message.length > 0 + ? `failed to inspect container health: ${message}` + : "failed to inspect container health", + daemonDown: isDockerDaemonDownMessage(message), + }); } return parseContainerState(stdout); }), @@ -360,12 +356,10 @@ export const legacyListVolumesByLabel = (spawner: Spawner, projectIdFilter: stri ); if (exitCode !== 0) { const message = stderr.trim(); - return yield* Effect.fail( - new LegacyDockerLifecycleListError({ - message: - message.length > 0 ? `failed to list volumes: ${message}` : "failed to list volumes", - }), - ); + return yield* new LegacyDockerLifecycleListError({ + message: + message.length > 0 ? `failed to list volumes: ${message}` : "failed to list volumes", + }); } return splitNonEmptyLines(stdout); }), diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.ts index eda19ae402..ca8f6bfaee 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.ts @@ -30,32 +30,24 @@ function getLastImageSegment(imageName: string): string { } /** - * `projectEnvValues` (dotenv-merged env, ambient-wins) is optional and - * additive — every existing caller that omits it keeps today's ambient-only - * behavior unchanged. Only callers that already have a project's dotenv - * values in scope (currently `start`, via `legacyGetRegistryImageUrlCandidates`) - * need to pass it so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set only in - * `supabase/.env`/project-root dotenv (not the ambient shell) is honored, - * matching the project dotenv files being loaded into the process env - * before the registry override is ever read. + * `projectEnvValues` is the explicit, already-merged environment visible to + * this resolver. Callers that do not load project dotenv values pass an empty + * record; no ambient process state is consulted here. */ function legacyGetRegistryOverride( - projectEnvValues?: Readonly<Record<string, string>>, + projectEnvValues: Readonly<Record<string, string>>, ): string | undefined { - const registry = ( - projectEnvValues?.[LEGACY_INTERNAL_IMAGE_REGISTRY_ENV] ?? - process.env[LEGACY_INTERNAL_IMAGE_REGISTRY_ENV] - )?.trim(); + const registry = projectEnvValues[LEGACY_INTERNAL_IMAGE_REGISTRY_ENV]?.trim(); return registry === undefined || registry.length === 0 ? undefined : registry.toLowerCase(); } -function legacyGetRegistry(projectEnvValues?: Readonly<Record<string, string>>): string { +function legacyGetRegistry(projectEnvValues: Readonly<Record<string, string>>): string { return legacyGetRegistryOverride(projectEnvValues) ?? DEFAULT_REGISTRY; } export function legacyGetRegistryImageUrl( imageName: string, - projectEnvValues?: Readonly<Record<string, string>>, + projectEnvValues: Readonly<Record<string, string>>, ): string { const registry = legacyGetRegistry(projectEnvValues); if (registry === DOCKER_HUB_REGISTRY) { @@ -66,7 +58,7 @@ export function legacyGetRegistryImageUrl( export function legacyGetRegistryImageUrlCandidates( imageName: string, - projectEnvValues?: Readonly<Record<string, string>>, + projectEnvValues: Readonly<Record<string, string>>, ): ReadonlyArray<string> { if (legacyGetRegistryOverride(projectEnvValues) !== undefined) { return [legacyGetRegistryImageUrl(imageName, projectEnvValues)]; diff --git a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts index b1c81c8ee2..a1e1ae5889 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-registry.unit.test.ts @@ -6,49 +6,44 @@ import { } from "./legacy-docker-registry.ts"; describe("legacyGetRegistryImageUrl", () => { - const withRegistry = <T>(value: string | undefined, fn: () => T): T => { - const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - if (value === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = value; - try { - return fn(); - } finally { - if (prev === undefined) delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; - else process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = prev; - } - }; + const withRegistry = <T>( + value: string | undefined, + fn: (env: Readonly<Record<string, string>>) => T, + ): T => fn(value === undefined ? {} : { SUPABASE_INTERNAL_IMAGE_REGISTRY: value }); it("defaults to the ECR mirror when the registry is unset", () => { - expect(withRegistry(undefined, () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36"))).toBe( - "public.ecr.aws/supabase/pg_prove:3.36", - ); + expect( + withRegistry(undefined, (env) => legacyGetRegistryImageUrl("supabase/pg_prove:3.36", env)), + ).toBe("public.ecr.aws/supabase/pg_prove:3.36"); }); it("treats a blank registry override as unset", () => { - expect(withRegistry(" ", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36"))).toBe( - "public.ecr.aws/supabase/pg_prove:3.36", - ); + expect( + withRegistry(" ", (env) => legacyGetRegistryImageUrl("supabase/pg_prove:3.36", env)), + ).toBe("public.ecr.aws/supabase/pg_prove:3.36"); }); it("returns the image unchanged for docker.io (case-insensitive)", () => { expect( - withRegistry("docker.io", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36")), + withRegistry("docker.io", (env) => legacyGetRegistryImageUrl("supabase/pg_prove:3.36", env)), ).toBe("supabase/pg_prove:3.36"); expect( - withRegistry("DOCKER.IO", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36")), + withRegistry("DOCKER.IO", (env) => legacyGetRegistryImageUrl("supabase/pg_prove:3.36", env)), ).toBe("supabase/pg_prove:3.36"); }); it("rewrites to <registry>/supabase/<image> for a custom mirror", () => { expect( - withRegistry("my.mirror.example", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36")), + withRegistry("my.mirror.example", (env) => + legacyGetRegistryImageUrl("supabase/pg_prove:3.36", env), + ), ).toBe("my.mirror.example/supabase/pg_prove:3.36"); }); it("returns fallback candidates when the registry is unset", () => { expect( - withRegistry(undefined, () => - legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138"), + withRegistry(undefined, (env) => + legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138", env), ), ).toEqual([ "public.ecr.aws/supabase/postgres:17.6.1.138", @@ -59,8 +54,8 @@ describe("legacyGetRegistryImageUrl", () => { it("dedupes an already-defaulted image in the fallback candidates", () => { expect( - withRegistry(undefined, () => - legacyGetRegistryImageUrlCandidates("public.ecr.aws/supabase/postgres:17.6.1.138"), + withRegistry(undefined, (env) => + legacyGetRegistryImageUrlCandidates("public.ecr.aws/supabase/postgres:17.6.1.138", env), ), ).toEqual([ "public.ecr.aws/supabase/postgres:17.6.1.138", @@ -71,18 +66,18 @@ describe("legacyGetRegistryImageUrl", () => { it("uses a single candidate when the registry is explicitly configured", () => { expect( - withRegistry("public.ecr.aws", () => - legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138"), + withRegistry("public.ecr.aws", (env) => + legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138", env), ), ).toEqual(["public.ecr.aws/supabase/postgres:17.6.1.138"]); expect( - withRegistry("docker.io", () => - legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138"), + withRegistry("docker.io", (env) => + legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138", env), ), ).toEqual(["supabase/postgres:17.6.1.138"]); expect( - withRegistry("my.mirror.example", () => - legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138"), + withRegistry("my.mirror.example", (env) => + legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138", env), ), ).toEqual(["my.mirror.example/supabase/postgres:17.6.1.138"]); }); @@ -92,28 +87,24 @@ describe("legacyGetRegistryImageUrl", () => { // (never set in the ambient shell) still reaches `GetRegistry()`. it("honors a projectEnvValues (dotenv)-only registry override, matching Go's post-Load os.Getenv", () => { expect( - withRegistry(undefined, () => + withRegistry(undefined, (env) => legacyGetRegistryImageUrl("supabase/pg_prove:3.36", { + ...env, SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", }), ), ).toBe("my.mirror.example/supabase/pg_prove:3.36"); expect( - withRegistry(undefined, () => + withRegistry(undefined, (env) => legacyGetRegistryImageUrlCandidates("supabase/postgres:17.6.1.138", { + ...env, SUPABASE_INTERNAL_IMAGE_REGISTRY: "my.mirror.example", }), ), ).toEqual(["my.mirror.example/supabase/postgres:17.6.1.138"]); }); - // `projectEnvValues` is the caller's own dotenv+ambient MERGED view (ambient - // wins ties during that merge, matching `godotenv.Load`'s "don't override - // already-set" semantics — see `legacyEnvOrDefault`'s doc comment for the - // same precedent), so checking it first is equivalent to checking the - // already-correctly-merged value first; falling back to bare `process.env` - // only covers a caller with no project-env context at all. - it("prefers projectEnvValues over a bare process.env read when both are set", () => { + it("uses the caller-provided merged environment", () => { expect( withRegistry("ambient.example", () => legacyGetRegistryImageUrl("supabase/pg_prove:3.36", { diff --git a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts index 6a083e78d6..ca5c8fec2a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-remove-all.ts @@ -181,15 +181,13 @@ export const legacyDockerRemoveAll = ( (result) => Result.isFailure(result) || result.success !== 0, ); if (failedStop !== undefined) { - return yield* Effect.fail( - new LegacyDockerRemoveAllStopError({ - message: `failed to stop container: ${ - Result.isFailure(failedStop) - ? legacyDescribeContainerCliFailure(failedStop.failure) - : `exit ${failedStop.success}` - }`, - }), - ); + return yield* new LegacyDockerRemoveAllStopError({ + message: `failed to stop container: ${ + Result.isFailure(failedStop) + ? legacyDescribeContainerCliFailure(failedStop.failure) + : `exit ${failedStop.success}` + }`, + }); } // The prune calls collect stdout (the CLI's deleted-ID report) instead of @@ -211,9 +209,9 @@ export const legacyDockerRemoveAll = ( ), ); if (containerPrune.exitCode !== 0) { - return yield* Effect.fail( - new LegacyDockerRemoveAllContainerPruneError({ message: "failed to prune containers" }), - ); + return yield* new LegacyDockerRemoveAllContainerPruneError({ + message: "failed to prune containers", + }); } yield* reportPruned(debug, "Pruned containers:", containerPrune.stdout); // Containers are now CONFIRMED removed — see `onContainersRemoved`'s doc comment for why this @@ -257,9 +255,9 @@ export const legacyDockerRemoveAll = ( ), ); if (volumePrune.exitCode !== 0) { - return yield* Effect.fail( - new LegacyDockerRemoveAllVolumePruneError({ message: "failed to prune volumes" }), - ); + return yield* new LegacyDockerRemoveAllVolumePruneError({ + message: "failed to prune volumes", + }); } // Inside the `deleteVolumes` branch, like Go's report inside the // `NoBackupVolume` block. @@ -281,9 +279,9 @@ export const legacyDockerRemoveAll = ( ), ); if (networkPrune.exitCode !== 0) { - return yield* Effect.fail( - new LegacyDockerRemoveAllNetworkPruneError({ message: "failed to prune networks" }), - ); + return yield* new LegacyDockerRemoveAllNetworkPruneError({ + message: "failed to prune networks", + }); } // Go: singular "network", unlike the other two reports. yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index 73002bf41b..e5d54393d8 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -1,7 +1,10 @@ -import { Effect, Layer, Stream } from "effect"; +import { Config, Effect, Layer, Option, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; -import { legacyIsBitbucketPipeline } from "./legacy-bitbucket-pipeline.ts"; +import { + LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY, + legacyIsBitbucketPipeline, +} from "./legacy-bitbucket-pipeline.ts"; import { containerCliExitCode, spawnContainerCli } from "./legacy-container-cli.ts"; import { legacyMakeDockerImageResolver } from "./legacy-docker-image-resolve.ts"; import { @@ -12,15 +15,19 @@ import { LegacyDockerRunError } from "./legacy-docker-run.errors.ts"; import { LEGACY_SUGGEST_DOCKER_INSTALL } from "./legacy-docker-suggest.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; -export const legacyDockerRunLayer: Layer.Layer< - LegacyDockerRun, - never, - ProcessControl | ChildProcessSpawner -> = Layer.effect( +export const legacyDockerRunLayer = Layer.effect( LegacyDockerRun, Effect.gen(function* () { const processControl = yield* ProcessControl; const spawner = yield* ChildProcessSpawner; + const ambientBitbucketMarker = yield* Config.option( + Config.string(LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY), + ); + const isAmbientBitbucket = + Option.isSome(ambientBitbucketMarker) && + legacyIsBitbucketPipeline({ + [LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY]: ambientBitbucketMarker.value, + }); const spawnError = () => // Never embed the spawn error verbatim: it can leak the full argv and @@ -45,6 +52,9 @@ export const legacyDockerRunLayer: Layer.Layer< const resolveImage = legacyMakeDockerImageResolver(spawner); + const applyBitbucketFilter = (opts: LegacyDockerRunOpts) => + legacyApplyBitbucketDockerFilter(opts, isAmbientBitbucket); + const withResolvedImage = ( opts: LegacyDockerRunOpts, ): Effect.Effect<LegacyDockerRunOpts, LegacyDockerRunError> => @@ -59,9 +69,7 @@ export const legacyDockerRunLayer: Layer.Layer< const teeStderr = captureOpts?.teeStderr ?? false; yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); const resolvedOpts = yield* withResolvedImage(opts); - const args = buildLegacyDockerArgs( - legacyApplyBitbucketDockerFilter(resolvedOpts, legacyIsBitbucketPipeline()), - ); + const args = buildLegacyDockerArgs(applyBitbucketFilter(resolvedOpts)); // Pipe stdout/stderr (rather than inherit) so the SQL dump can be // captured and redirected to `--file`/post-processing. `dockerExec` // does the same: stdout → caller's writer, stderr → `MultiWriter(os.Stderr, @@ -116,9 +124,7 @@ export const legacyDockerRunLayer: Layer.Layer< const captureStderr = streamOpts.captureStderr ?? true; yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); const resolvedOpts = yield* withResolvedImage(opts); - const args = buildLegacyDockerArgs( - legacyApplyBitbucketDockerFilter(resolvedOpts, legacyIsBitbucketPipeline()), - ); + const args = buildLegacyDockerArgs(applyBitbucketFilter(resolvedOpts)); const handle = yield* spawnContainerCli(spawner, args, { stdin: "inherit", stdout: "pipe", @@ -162,9 +168,7 @@ export const legacyDockerRunLayer: Layer.Layer< Effect.gen(function* () { yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); const resolvedOpts = yield* withResolvedImage(opts); - const args = buildLegacyDockerArgs( - legacyApplyBitbucketDockerFilter(resolvedOpts, legacyIsBitbucketPipeline()), - ); + const args = buildLegacyDockerArgs(applyBitbucketFilter(resolvedOpts)); // Pass run env (incl. PGPASSWORD) through the docker child's own // environment, not the argv. `buildLegacyDockerArgs` emits the // key-only `-e KEY` form, so docker inherits each value from here diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.unit.test.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.unit.test.ts new file mode 100644 index 0000000000..5b97906669 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Deferred, Effect, Layer, Option, Sink, Stream } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { mockProcessControl } from "../../../tests/helpers/mocks.ts"; +import { legacyDockerRunLayer } from "./legacy-docker-run.layer.ts"; +import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; + +const opts = { + image: "supabase/postgres:17", + cmd: ["echo", "ok"], + env: {}, + binds: ["cache-volume:/cache", "/tmp/project:/workspace"], + workingDir: Option.some("/workspace"), + securityOpt: ["seccomp=unconfined"], + extraHosts: [], + network: { _tag: "host" as const }, + skipImageResolve: true, +}; + +function mockSpawner() { + const spawned: Array<ReadonlyArray<string>> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = command._tag === "StandardCommand" ? command.args : []; + spawned.push(args); + const exitCode = yield* Deferred.make<ChildProcessSpawner.ExitCode>(); + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Deferred.await(exitCode), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { spawner, spawned }; +} + +function runWithEnvironment( + ambient: Record<string, string>, + containerEnv: Readonly<Record<string, string>> = {}, + operation: "run" | "capture" | "stream" = "run", +) { + const mock = mockSpawner(); + const processControl = mockProcessControl(); + const configProvider = ConfigProvider.fromEnv({ env: ambient }); + const layer = legacyDockerRunLayer.pipe( + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, mock.spawner)), + Layer.provide(processControl.layer), + Layer.provide(Layer.succeed(ConfigProvider.ConfigProvider, configProvider)), + ); + return { + mock, + program: Effect.gen(function* () { + const docker = yield* LegacyDockerRun; + const runOpts = { ...opts, env: containerEnv }; + if (operation === "capture") { + yield* docker.runCapture(runOpts); + } else if (operation === "stream") { + yield* docker.runStream(runOpts, { onStdout: () => Effect.void }); + } else { + yield* docker.run(runOpts); + } + }).pipe(Effect.provide(layer)), + }; +} + +describe("legacyDockerRunLayer", () => { + it.live("uses the ambient Bitbucket marker when container env is empty", () => { + const { mock, program } = runWithEnvironment({ BITBUCKET_CLONE_DIR: "/build" }); + return program.pipe( + Effect.map(() => { + const args = mock.spawned[0] ?? []; + expect(args).toContain("/tmp/project:/workspace"); + expect(args).not.toContain("cache-volume:/cache"); + expect(args).not.toContain("--security-opt"); + }), + ); + }); + + it.live("does not treat a container-only Bitbucket marker as ambient context", () => { + const { mock, program } = runWithEnvironment({}, { BITBUCKET_CLONE_DIR: "/container" }); + return program.pipe( + Effect.map(() => { + const args = mock.spawned[0] ?? []; + expect(args).toContain("cache-volume:/cache"); + expect(args).toContain("--security-opt"); + }), + ); + }); + + it.live("applies the ambient marker consistently to capture and stream", () => { + const capture = runWithEnvironment({ BITBUCKET_CLONE_DIR: "/build" }, {}, "capture"); + const stream = runWithEnvironment({ BITBUCKET_CLONE_DIR: "/build" }, {}, "stream"); + return Effect.all([capture.program, stream.program]).pipe( + Effect.map(() => { + for (const spawned of [capture.mock.spawned, stream.mock.spawned]) { + const args = spawned[0] ?? []; + expect(args).not.toContain("cache-volume:/cache"); + expect(args).not.toContain("--security-opt"); + } + }), + ); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts index 65a850247b..fb33a51f74 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-image.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; @@ -17,40 +14,36 @@ const resolve = (workdir: string, denoVersion: number) => describe("legacyResolveEdgeRuntimeImage", () => { it.effect("returns the edge-runtime image from the Dockerfile when nothing is pinned", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); - return resolve(dir, 2).pipe( - Effect.tap((image) => - Effect.sync(() => { - expect(image).toBe(dockerfileServiceImage("edgeruntime")); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-edge-img-" }); + const image = yield* resolve(dir, 2); + expect(image).toBe(dockerfileServiceImage("edgeruntime")); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("honors the pinned tag in .temp/edge-runtime-version", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "edge-runtime-version"), "v9.9.9\n"); - return resolve(dir, 2).pipe( - Effect.tap((image) => - Effect.sync(() => { - expect(image).toBe("supabase/edge-runtime:v9.9.9"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-edge-img-" }); + const tempDir = path.join(dir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "edge-runtime-version"), "v9.9.9\n"); + const image = yield* resolve(dir, 2); + expect(image).toBe("supabase/edge-runtime:v9.9.9"); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("selects the deno1 image when deno_version = 1", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-edge-img-")); - return resolve(dir, 1).pipe( - Effect.tap((image) => - Effect.sync(() => { - expect(image).toBe("supabase/edge-runtime:v1.68.4"); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-edge-img-" }); + const image = yield* resolve(dir, 1); + expect(image).toBe("supabase/edge-runtime:v1.68.4"); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts index f75ef0957b..22663e73ba 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.integration.test.ts @@ -1,10 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; @@ -12,6 +8,7 @@ import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts } from "./legacy-docker-run.service.ts"; import { legacyEdgeRuntimeScriptLayer } from "./legacy-edge-runtime-script.layer.ts"; import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; // Fakes a `docker run --rm` capture: the pg-delta scripts throw to force the // worker to exit, so a real diff always comes back with a non-zero exit and @@ -76,6 +73,7 @@ function setup( Layer.succeed(LegacyDebugFlag, false), Layer.succeed(LegacyNetworkIdFlag, Option.none<string>()), BunServices.layer, + makeLegacyViperEnvLayer(), ), ), ); @@ -150,45 +148,38 @@ describe("legacyEdgeRuntimeScriptLayer sentinel handling", () => { // reflects bootstrap's real target. `opts.workdir` (threaded from // `LegacyPgDeltaContext.cwd` by every pg-delta/migra caller) must win the // pin-file lookup instead. - const configWorkdir = mkdtempSync(join(tmpdir(), "edge-runtime-config-")); - const callerWorkdir = mkdtempSync(join(tmpdir(), "edge-runtime-caller-")); - mkdirSync(join(configWorkdir, "supabase", ".temp"), { recursive: true }); - writeFileSync( - join(configWorkdir, "supabase", ".temp", "edge-runtime-version"), - "v-from-config\n", - ); - mkdirSync(join(callerWorkdir, "supabase", ".temp"), { recursive: true }); - writeFileSync( - join(callerWorkdir, "supabase", ".temp", "edge-runtime-version"), - "v-from-caller\n", - ); - - const { layer, docker } = setup( - { exitCode: 1, stdout: "", stderr: "main worker has been destroyed\n" }, - { cliConfigWorkdir: configWorkdir }, - ); - return Effect.gen(function* () { - const edge = yield* LegacyEdgeRuntimeScript; - yield* edge.run({ - script: "console.log('x')", - env: {}, - binds: [], - errPrefix: "error diffing schema", - denoVersion: 2, - workdir: callerWorkdir, - }); - expect(docker.lastOpts?.image).toContain("edge-runtime:v-from-caller"); - expect(docker.lastOpts?.image).not.toContain("v-from-config"); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - rmSync(configWorkdir, { recursive: true, force: true }); - rmSync(callerWorkdir, { recursive: true, force: true }); - }), - ), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configWorkdir = yield* fs.makeTempDirectory({ prefix: "edge-runtime-config-" }); + const callerWorkdir = yield* fs.makeTempDirectory({ prefix: "edge-runtime-caller-" }); + const configTemp = path.join(configWorkdir, "supabase", ".temp"); + const callerTemp = path.join(callerWorkdir, "supabase", ".temp"); + yield* fs.makeDirectory(configTemp, { recursive: true }); + yield* fs.writeFileString(path.join(configTemp, "edge-runtime-version"), "v-from-config\n"); + yield* fs.makeDirectory(callerTemp, { recursive: true }); + yield* fs.writeFileString(path.join(callerTemp, "edge-runtime-version"), "v-from-caller\n"); + + const { layer, docker } = setup( + { exitCode: 1, stdout: "", stderr: "main worker has been destroyed\n" }, + { cliConfigWorkdir: configWorkdir }, + ); + yield* Effect.gen(function* () { + const edge = yield* LegacyEdgeRuntimeScript; + yield* edge.run({ + script: "console.log('x')", + env: {}, + binds: [], + errPrefix: "error diffing schema", + denoVersion: 2, + workdir: callerWorkdir, + }); + expect(docker.lastOpts?.image).toContain("edge-runtime:v-from-caller"); + expect(docker.lastOpts?.image).not.toContain("v-from-config"); + }).pipe(Effect.provide(layer)); + yield* fs.remove(configWorkdir, { recursive: true, force: true }); + yield* fs.remove(callerWorkdir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts index e0e18ef309..3f6d70937d 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.layer.ts @@ -3,6 +3,7 @@ import * as Net from "node:net"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyReadDbToml } from "./legacy-db-config.toml-read.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; @@ -52,6 +53,12 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( const debug = yield* LegacyDebugFlag; const networkIdFlag = yield* LegacyNetworkIdFlag; const runtimeInfo = yield* RuntimeInfo; + const viperEnv = yield* LegacyViperEnv; + const runtime = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed(Path.Path, path), + Layer.succeed(LegacyViperEnv, viperEnv), + ); // `DockerStart` appends `host.docker.internal:host-gateway` to every // container's ExtraHosts on Linux only (build-tag `extraHosts` in // `apps/cli-go/internal/utils/docker_linux.go:8`; the append at `docker.go:266` @@ -103,6 +110,7 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( )).denoVersion; const registryImage = legacyGetRegistryImageUrl( yield* legacyResolveEdgeRuntimeImage(fs, path, workdir, denoVersion), + opts.projectEnvValues ?? {}, ); const port = yield* allocateFreeHostPort; const startCmd = legacyBuildEdgeRuntimeStartCmd({ port, debug }).join(" "); @@ -151,11 +159,9 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( // worker after the script completed (the script's output is still // valid). Any other non-zero exit is a real failure. if (result.exitCode !== 0 && !result.stderr.includes("main worker has been destroyed")) { - return yield* Effect.fail( - new LegacyEdgeRuntimeScriptError({ - message: `${opts.errPrefix}: error running container: exit ${result.exitCode}:\n${result.stderr}`, - }), - ); + return yield* new LegacyEdgeRuntimeScriptError({ + message: `${opts.errPrefix}: error running container: exit ${result.exitCode}:\n${result.stderr}`, + }); } // The pg-delta templates force the worker to exit by throwing, so a @@ -166,18 +172,16 @@ export const legacyEdgeRuntimeScriptLayer = Layer.effect( // empty diff. Byte-for-byte port of Go's check in // apps/cli-go/internal/utils/edgeruntime.go. if (result.stderr.includes(LEGACY_EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL)) { - return yield* Effect.fail( - new LegacyEdgeRuntimeScriptError({ - message: `${opts.errPrefix}: error running script:\n${result.stderr}`, - }), - ); + return yield* new LegacyEdgeRuntimeScriptError({ + message: `${opts.errPrefix}: error running script:\n${result.stderr}`, + }); } return { stdout: new TextDecoder().decode(result.stdout), stderr: result.stderr, }; - }), + }).pipe(Effect.provide(runtime)), }); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts index 1c6531d6af..c8829fd854 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.service.ts @@ -34,6 +34,8 @@ export interface LegacyEdgeRuntimeRunOpts { readonly extraFiles?: ReadonlyArray<LegacyEdgeRuntimeFile>; /** Extra container env appended after `env` (`WithExtraEnv`). */ readonly extraEnv?: Readonly<Record<string, string>>; + /** Explicitly merged project environment used for registry/image resolution. */ + readonly projectEnvValues?: Readonly<Record<string, string>>; /** * Effective `edge_runtime.deno_version` for this run, used to pick the image tag * (`1` → the `deno1` image). Lets a caller that has the remote-merged config (e.g. diff --git a/apps/cli/src/legacy/shared/legacy-ensure-login.ts b/apps/cli/src/legacy/shared/legacy-ensure-login.ts index 4facb75f86..632ba15856 100644 --- a/apps/cli/src/legacy/shared/legacy-ensure-login.ts +++ b/apps/cli/src/legacy/shared/legacy-ensure-login.ts @@ -137,14 +137,12 @@ export const legacyBrowserLogin = Effect.fnUntraced(function* (opts: LegacyBrows Effect.gen(function* () { const failures = failuresSoFar + 1; if (failures > MAX_LOGIN_RETRIES) { - return yield* Effect.fail( - new LegacyLoginFailedError({ - message: err.message, - statusCode: err.statusCode, - network: err.network, - decode: err.decode, - }), - ); + return yield* new LegacyLoginFailedError({ + message: err.message, + statusCode: err.statusCode, + network: err.network, + decode: err.decode, + }); } yield* output.raw(`${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, "stderr"); return yield* verifyWithRetries(failures); diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts index 2c7bd7085d..d494a9c70c 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { ConfigProvider, Effect, Layer } from "effect"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { LegacyExperimentalFlag } from "../../shared/legacy/global-flags.ts"; @@ -7,10 +7,18 @@ import { LegacyExperimentalRequiredError, legacyRequireExperimental, } from "./legacy-experimental-gate.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; -const ENV = "SUPABASE_EXPERIMENTAL"; -const withFlag = (value: boolean, args: ReadonlyArray<string> = []) => - Layer.mergeAll(Layer.succeed(LegacyExperimentalFlag, value), Layer.succeed(CliArgs, { args })); +const withFlag = ( + value: boolean, + args: ReadonlyArray<string> = [], + env: Readonly<Record<string, string>> = {}, +) => + Layer.mergeAll( + Layer.succeed(LegacyExperimentalFlag, value), + Layer.succeed(CliArgs, { args }), + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })), + ); describe("legacyRequireExperimental", () => { it.effect("passes when --experimental is set", () => @@ -19,14 +27,10 @@ describe("legacyRequireExperimental", () => { it.effect("fails with Go's byte-exact message when neither flag nor env is set", () => Effect.gen(function* () { - const saved = process.env[ENV]; - delete process.env[ENV]; const error = yield* legacyRequireExperimental.pipe( Effect.provide(withFlag(false)), Effect.flip, ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(error).toBeInstanceOf(LegacyExperimentalRequiredError); expect(error.message).toBe("must set the --experimental flag to run this command"); }), @@ -34,14 +38,10 @@ describe("legacyRequireExperimental", () => { it.effect("passes when SUPABASE_EXPERIMENTAL=1 even without the flag (viper AutomaticEnv)", () => Effect.gen(function* () { - const saved = process.env[ENV]; - process.env[ENV] = "1"; const exit = yield* legacyRequireExperimental.pipe( - Effect.provide(withFlag(false)), + Effect.provide(withFlag(false, [], { SUPABASE_EXPERIMENTAL: "1" })), Effect.exit, ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(exit._tag).toBe("Success"); }), ); @@ -53,14 +53,10 @@ describe("legacyRequireExperimental", () => { // viper's bound-pflag lookup returns the flag value whenever Changed is true — // BEFORE falling back to AutomaticEnv — so an // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1. - const saved = process.env[ENV]; - process.env[ENV] = "1"; const error = yield* legacyRequireExperimental.pipe( - Effect.provide(withFlag(false, ["--experimental=false"])), + Effect.provide(withFlag(false, ["--experimental=false"], { SUPABASE_EXPERIMENTAL: "1" })), Effect.flip, ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(error).toBeInstanceOf(LegacyExperimentalRequiredError); }), ); @@ -77,14 +73,12 @@ describe("legacyRequireExperimental", () => { // as `db pull -- --experimental=false` — must not be mistaken for an explicit // `--experimental=false` and must not suppress the SUPABASE_EXPERIMENTAL=1 // AutomaticEnv fallback. - const saved = process.env[ENV]; - process.env[ENV] = "1"; const exit = yield* legacyRequireExperimental.pipe( - Effect.provide(withFlag(false, ["--", "--experimental=false"])), + Effect.provide( + withFlag(false, ["--", "--experimental=false"], { SUPABASE_EXPERIMENTAL: "1" }), + ), Effect.exit, ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; expect(exit._tag).toBe("Success"); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-functions-go-config.ts b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts index 14c52a698b..23b5d7813c 100644 --- a/apps/cli/src/legacy/shared/legacy-functions-go-config.ts +++ b/apps/cli/src/legacy/shared/legacy-functions-go-config.ts @@ -1,10 +1,11 @@ -import { Effect } from "effect"; +import { Cause, Crypto, Effect } from "effect"; import type { FunctionsGoConfigCompat } from "../../shared/functions/functions-config.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts"; import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; -function toError(cause: unknown): Error { - return cause instanceof Error ? cause : new Error(String(cause)); +function toError(cause: unknown): Cause.UnknownError { + return new Cause.UnknownError(cause, cause instanceof Error ? cause.message : String(cause)); } /** @@ -22,47 +23,51 @@ function toError(cause: unknown): Error { * `projectId`/`edgeRuntimeDenoVersion` and the validation side effect * (throws on the first Go-parity failure) matter to these three commands. */ -export const legacyFunctionsGoConfigCompat: FunctionsGoConfigCompat = { - load: ({ projectRoot, projectRef }) => - Effect.gen(function* () { - const context = yield* legacyLoadLocalProjectContext(projectRoot, toError, projectRef); - const validated = yield* Effect.try({ - try: () => - legacyResolveLocalConfigValues( - context.config, - context.hostname, - projectRoot, - context.projectEnvValues, - context.loaded?.document, - // No `[remotes.<ref>]` override-tier gating (empty set, the - // parameter default): the remote block itself already merged over - // the base config at file level via `legacyLoadLocalProjectContext`'s - // `projectRef` threading above. Known narrow divergence: without - // the key set, an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` - // still beats a matched remote block's own `deno_version`, where - // Go's OVERRIDE-tier `v.Set` would win — computing the keys here - // needs `legacy-db-config.toml-read.ts`'s remote-resolution - // pipeline, which this `loadProjectConfig`-based path doesn't run - // (review round on CLI-1963). - undefined, - projectRef, - ), - catch: toError, - }); - return { - loaded: context.loaded, - projectEnvValues: context.projectEnvValues, - // `context.projectId`, NOT `validated.projectId`: the context's id is - // the one built for Docker naming/labels — sanitized, `--project-ref` - // defaulted, and `SUPABASE_PROJECT_ID`-gated when a `[remotes.<ref>]` - // block matched (Go installs the remote's own `project_id` at viper's - // OVERRIDE tier, above `AutomaticEnv` — `pkg/config/config.go:718-724`; - // see `legacy-local-project-context.ts`'s gate, review - // PRRT_kwDOErm0O86XHGDL). `validated.projectId` exists only to feed - // `legacyValidateResolvedConfig`'s emptiness check and deliberately - // skips that gate — see its own doc comment. - projectId: context.projectId, - denoVersion: validated.edgeRuntimeDenoVersion, - }; - }), -}; +export const legacyFunctionsGoConfigCompat = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const viperEnv = yield* LegacyViperEnv; + const compat: FunctionsGoConfigCompat = { + load: ({ projectRoot, projectRef }) => + Effect.gen(function* () { + const context = yield* legacyLoadLocalProjectContext(projectRoot, toError, projectRef); + const validated = yield* legacyResolveLocalConfigValues( + context.config, + context.hostname, + projectRoot, + context.projectEnvValues, + context.loaded?.document, + // No `[remotes.<ref>]` override-tier gating (empty set, the + // parameter default): the remote block itself already merged over + // the base config at file level via `legacyLoadLocalProjectContext`'s + // `projectRef` threading above. Known narrow divergence: without + // the key set, an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` + // still beats a matched remote block's own `deno_version`, where + // Go's OVERRIDE-tier `v.Set` would win — computing the keys here + // needs `legacy-db-config.toml-read.ts`'s remote-resolution + // pipeline, which this `loadProjectConfig`-based path doesn't run + // (review round on CLI-1963). + undefined, + projectRef, + ).pipe(Effect.mapError(toError)); + return { + loaded: context.loaded, + projectEnvValues: context.projectEnvValues, + // `context.projectId`, NOT `validated.projectId`: the context's id is + // the one built for Docker naming/labels — sanitized, `--project-ref` + // defaulted, and `SUPABASE_PROJECT_ID`-gated when a `[remotes.<ref>]` + // block matched (Go installs the remote's own `project_id` at viper's + // OVERRIDE tier, above `AutomaticEnv` — `pkg/config/config.go:718-724`; + // see `legacy-local-project-context.ts`'s gate, review + // PRRT_kwDOErm0O86XHGDL). `validated.projectId` exists only to feed + // `legacyValidateResolvedConfig`'s emptiness check and deliberately + // skips that gate — see its own doc comment. + projectId: context.projectId, + denoVersion: validated.edgeRuntimeDenoVersion, + }; + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(LegacyViperEnv, viperEnv), + ), + }; + return compat; +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index 466dad4095..44cb729762 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -362,7 +362,8 @@ export function legacySignJwtWithJwk(jwk: LegacyJwk, payloadJson: string): strin export function legacyGenerateAsymmetricGoJwt( jwk: LegacyJwk, role: "anon" | "service_role", + nowSeconds: number, ): string { - const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + const expiresAt = nowSeconds + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; return legacySignJwtWithJwk(jwk, JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt })); } diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index 1e674d3a72..8656aa3b49 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -1,6 +1,7 @@ import { createHmac, generateKeyPairSync } from "node:crypto"; import { importJWK, jwtVerify } from "jose"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { DateTime, Effect } from "effect"; import { legacyAssertDecodableJwkAlgorithm, @@ -11,6 +12,7 @@ import { } from "./legacy-go-jwt.ts"; const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; +const JWT_TEST_CURRENT_DATE = DateTime.toDateUtc(DateTime.makeUnsafe("1970-01-01T00:00:00Z")); function generateRsaJwk(kid?: string): LegacyJwk { const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); @@ -89,42 +91,56 @@ describe("legacyGenerateGoJwt", () => { }); describe("legacyGenerateAsymmetricGoJwt", () => { - it("signs and verifies an RS256 token from an RSA JWK", async () => { - const jwk = generateRsaJwk("rsa-kid"); - const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); - const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); - const { payload, protectedHeader } = await jwtVerify(token, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); - }); - - it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { - // `jwkToRSAPrivateKey` - // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` - // alone, and Go's stdlib derives the CRT params itself when absent. A - // hand-authored signing-keys file that omits them (common — RFC 7517 marks - // them optional) must still sign successfully here. - const jwk = generateRsaJwk("rsa-kid"); - const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; - const token = legacyGenerateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); - const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); - const { payload, protectedHeader } = await jwtVerify(token, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); - }); - - it("signs and verifies an ES256 token from an EC JWK", async () => { - const jwk = generateEcJwk("ec-kid"); - const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role"); - const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); - const { payload, protectedHeader } = await jwtVerify(token, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); - expect(protectedHeader).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); - }); + it.effect("signs and verifies an RS256 token from an RSA JWK", () => + Effect.gen(function* () { + const jwk = generateRsaJwk("rsa-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon", 0); + const publicKey = yield* Effect.promise(() => importJWK(publicJwkOf(jwk), "RS256")); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(token, publicKey, { currentDate: JWT_TEST_CURRENT_DATE }), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }), + ); + + it.effect( + "signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", + () => + Effect.gen(function* () { + // `jwkToRSAPrivateKey` + // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` + // alone, and Go's stdlib derives the CRT params itself when absent. A + // hand-authored signing-keys file that omits them (common — RFC 7517 marks + // them optional) must still sign successfully here. + const jwk = generateRsaJwk("rsa-kid"); + const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; + const token = legacyGenerateAsymmetricGoJwt(jwkWithoutCrtParams, "anon", 0); + const publicKey = yield* Effect.promise(() => importJWK(publicJwkOf(jwk), "RS256")); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(token, publicKey, { currentDate: JWT_TEST_CURRENT_DATE }), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }), + ); + + it.effect("signs and verifies an ES256 token from an EC JWK", () => + Effect.gen(function* () { + const jwk = generateEcJwk("ec-kid"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "service_role", 0); + const publicKey = yield* Effect.promise(() => importJWK(publicJwkOf(jwk), "ES256")); + const { payload, protectedHeader } = yield* Effect.promise(() => + jwtVerify(token, publicKey, { currentDate: JWT_TEST_CURRENT_DATE }), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); + expect(protectedHeader).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }), + ); it("omits the kid header entirely when the JWK has no kid", () => { const jwk = generateRsaJwk(); - const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon", 0); const [header] = token.split("."); const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); @@ -132,25 +148,24 @@ describe("legacyGenerateAsymmetricGoJwt", () => { it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { const jwk = generateRsaJwk(); - const before = Math.floor(Date.now() / 1000); - const token = legacyGenerateAsymmetricGoJwt(jwk, "anon"); + const nowSeconds = 1_000_000; + const token = legacyGenerateAsymmetricGoJwt(jwk, "anon", nowSeconds); const [, payload] = token.split("."); const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; - expect(decoded.exp).toBeGreaterThanOrEqual(before + tenYearsSeconds); - expect(decoded.exp).toBeLessThan(before + tenYearsSeconds + 10); + expect(decoded.exp).toBe(nowSeconds + tenYearsSeconds); }); it("rejects an unsupported algorithm", () => { const jwk = { ...generateRsaJwk(), alg: "RS512" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon", 0)).toThrow( "unsupported algorithm: RS512", ); }); it("rejects a JWK with no algorithm", () => { const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); - expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutAlg, "anon", 0)).toThrow( "unsupported algorithm: ", ); }); @@ -165,14 +180,14 @@ describe("legacyGenerateAsymmetricGoJwt", () => { // input — corrected here. it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { const jwk = { ...generateEcJwk(), alg: "RS256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon", 0)).toThrow( "failed to sign JWT: key is of invalid type: RSA sign expects *rsa.PrivateKey", ); }); it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { const jwk = { ...generateRsaJwk(), alg: "ES256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon", 0)).toThrow( "failed to sign JWT: key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey", ); }); @@ -180,7 +195,7 @@ describe("legacyGenerateAsymmetricGoJwt", () => { it("rejects an ES256 EC key whose curve is not P-256, wrapped like Go's GenerateAsymmetricJWT", () => { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon", 0)).toThrow( "failed to convert JWK to private key: unsupported curve: P-384", ); }); @@ -189,7 +204,7 @@ describe("legacyGenerateAsymmetricGoJwt", () => { // `bearerjwt_test.go`'s "throws error on unsupported kty" fixture uses exactly // this shape (`{"kty": "oct"}`, no `alg`) — kty is checked before alg regardless. const jwk = { kty: "oct" } as LegacyJwk; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon", 0)).toThrow( "failed to convert JWK to private key: unsupported key type: oct", ); }); @@ -197,7 +212,7 @@ describe("legacyGenerateAsymmetricGoJwt", () => { it("rejects an ES256 EC key with no curve at all", () => { const jwk = generateEcJwk(); const { crv: _crv, ...jwkWithoutCurve } = jwk; - expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon", 0)).toThrow( "failed to convert JWK to private key: unsupported curve: ", ); }); @@ -211,7 +226,7 @@ describe("legacyGenerateAsymmetricGoJwt", () => { // padding and would otherwise sign successfully, minting a token Go could never produce. const jwk = generateEcJwk("ec-kid"); const padded = { ...jwk, x: `${jwk.x}=` }; - expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon", 0)).toThrow( /^failed to convert JWK to private key: failed to decode x coordinate: illegal base64 data at input byte \d+$/, ); }); @@ -219,32 +234,38 @@ describe("legacyGenerateAsymmetricGoJwt", () => { it("rejects a padded RSA modulus the same way", () => { const jwk = generateRsaJwk("rsa-kid"); const padded = { ...jwk, n: `${jwk.n}=` }; - expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon")).toThrow( + expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon", 0)).toThrow( /^failed to convert JWK to private key: failed to decode modulus: illegal base64 data at input byte \d+$/, ); }); it("still signs successfully for unpadded (correctly-encoded) coordinates", () => { const jwk = generateEcJwk("ec-kid"); - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).not.toThrow(); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon", 0)).not.toThrow(); }); }); describe("legacySignJwtWithJwk", () => { - it("signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", async () => { - const jwk = generateEcJwk("ec-kid"); - // Deliberately NOT alphabetically sorted and containing characters Go's - // `encoding/json` would HTML-escape (`&`) — this function must sign exactly - // the bytes it's given, leaving ordering/escaping decisions to the caller. - const payloadJson = '{"role":"postgres","sb-role":"mgmt-api & co"}'; - const token = legacySignJwtWithJwk(jwk, payloadJson); - const [, payload] = token.split("."); - expect(decodeSegment(payload ?? "")).toBe(payloadJson); - - const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); - const { payload: verified } = await jwtVerify(token, publicKey); - expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); - }); + it.effect( + "signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", + () => + Effect.gen(function* () { + const jwk = generateEcJwk("ec-kid"); + // Deliberately NOT alphabetically sorted and containing characters Go's + // `encoding/json` would HTML-escape (`&`) — this function must sign exactly + // the bytes it's given, leaving ordering/escaping decisions to the caller. + const payloadJson = '{"role":"postgres","sb-role":"mgmt-api & co"}'; + const token = legacySignJwtWithJwk(jwk, payloadJson); + const [, payload] = token.split("."); + expect(decodeSegment(payload ?? "")).toBe(payloadJson); + + const publicKey = yield* Effect.promise(() => importJWK(publicJwkOf(jwk), "ES256")); + const { payload: verified } = yield* Effect.promise(() => + jwtVerify(token, publicKey, { currentDate: JWT_TEST_CURRENT_DATE }), + ); + expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); + }), + ); it("HTML-escapes the kid in the header like Go's json.Marshal, unlike JSON.stringify (CLI-1961 Codex review finding)", () => { // `token.SignedString` marshals the protected header via `encoding/json`'s diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 3f8b6a8a5f..e97b34e091 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -1,3 +1,4 @@ +import { Data } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, @@ -530,8 +531,8 @@ export function goStringCompare(a: string, b: string): number { * r3685767973). The ASCII-only {@link isDigit} stays for the scalar parser. */ function yamlKeyLess(a: string, b: string): boolean { - const ar = [...a]; - const br = [...b]; + const ar = Array.from(a); + const br = Array.from(b); let digits = false; for (let i = 0; i < ar.length && i < br.length; i++) { const ac = ar[i] as string; @@ -1055,11 +1056,12 @@ function yamlBlockLiteral(s: string, indent: number): string { * `nullable.Nullable` field (`map[bool]T` has a non-string key type — observed * on `snippets list -o toml`) or a `nil` element inside an inline array. */ -export class LegacyGoTomlEncodeError extends Error { +export class LegacyGoTomlEncodeError extends Data.TaggedError("LegacyGoTomlEncodeError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "LegacyGoTomlEncodeError"; constructor(message = "toml: cannot encode a map with non-string key type") { - super(message); - this.name = "LegacyGoTomlEncodeError"; + super({ message }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts index e5562d9b7a..9ace72ded3 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-drift.unit.test.ts @@ -1,6 +1,7 @@ -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; import { LEGACY_GO_BRANCH_RESPONSE } from "../commands/branches/branches.go-payload.ts"; import { LEGACY_GO_ORGANIZATION_RESPONSE } from "../commands/orgs/orgs.go-payload.ts"; @@ -66,14 +67,15 @@ const GO_PAYLOAD_SPEC_REGISTRY: ReadonlyArray<GoPayloadSpecEntry> = [ ]; describe("go-payload specs vs types.gen.go (drift check)", () => { - const source = readFileSync(TYPES_GEN_GO_PATH, "utf8"); - - it.each(GO_PAYLOAD_SPEC_REGISTRY)( + it.effect.each(GO_PAYLOAD_SPEC_REGISTRY)( "$specName matches Go's $goTypeName with zero drift", - ({ spec, goTypeName }) => { - const parsed = parseGoStruct(source, goTypeName); - expect(compareLegacyGoTypeToParsedGoType(spec, parsed)).toEqual([]); - }, + ({ spec, goTypeName }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source = yield* fs.readFileString(TYPES_GEN_GO_PATH); + const parsed = parseGoStruct(source, goTypeName); + expect(compareLegacyGoTypeToParsedGoType(spec, parsed)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), ); it("has teeth: reports a mismatch when a field is dropped from the real struct", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts index 568c57f9f2..b62af58538 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.types-gen-parser.ts @@ -268,7 +268,10 @@ function compareType( } if (parsed.kind === "unknown") { - console.warn(`[types.gen.go drift check] skipping ${path}: could not classify the Go type`); + mismatches.push({ + path, + message: `could not classify the Go type at ${path}`, + }); return; } diff --git a/apps/cli/src/legacy/shared/legacy-hostname.ts b/apps/cli/src/legacy/shared/legacy-hostname.ts index 8bafb29722..5c19536b6b 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.ts @@ -1,98 +1,160 @@ -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { Config, Crypto, Effect, Encoding, FileSystem, Option, Path, Schema } from "effect"; +import type * as PlatformError from "effect/PlatformError"; -const LOCAL_HOST = "127.0.0.1"; -const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ +const LOCAL_HOST = "127.0.0.1"; const DEFAULT_CONTEXT_NAME = "default"; -/** - * Docker CLI's config directory: `$DOCKER_CONFIG` or `~/.docker` - * (`docker/cli` `cliconfig.Dir()`), read from here rather than - * `client.Client`'s own resolution since this module never spawns a real - * Docker client — it only needs the same two on-disk files that resolution - * reads. - */ -function dockerConfigDir(): string { - const override = process.env["DOCKER_CONFIG"]; - return override !== undefined && override.length > 0 ? override : join(homedir(), ".docker"); +const DockerConfigSchema = Schema.Struct({ + currentContext: Schema.optional(Schema.String), +}); + +const DockerContextSchema = Schema.Struct({ + Endpoints: Schema.optional( + Schema.Struct({ + docker: Schema.optional( + Schema.Struct({ + Host: Schema.optional(Schema.String), + }), + ), + }), + ), +}); + +interface HostEnvironment { + readonly servicesHostname: string | undefined; + readonly dockerHost: string | undefined; + readonly dockerContext: string | undefined; + readonly dockerConfig: string | undefined; + readonly home: string | undefined; } -/** - * `cli.CurrentContext()` name resolution (`docker/cli` - * `cli/command/cli.go`'s `resolveContextName`): `DOCKER_CONTEXT` env, else the - * config file's `currentContext`, else `"default"`. Only reached when - * `DOCKER_HOST` is unset — `resolveContextName` itself forces `"default"` - * when `DOCKER_HOST`/`--host` is set, which {@link legacyGetHostname} below - * already handles as its own, earlier branch. - */ -function currentDockerContextName(): string { - const fromEnv = process.env["DOCKER_CONTEXT"]; - if (fromEnv !== undefined && fromEnv.length > 0) { - return fromEnv; - } - try { - const config = JSON.parse(readFileSync(join(dockerConfigDir(), "config.json"), "utf8")) as { - currentContext?: unknown; +function readDockerConfig( + fs: FileSystem.FileSystem, + path: string, +): Effect.Effect<Option.Option<Schema.Schema.Type<typeof DockerConfigSchema>>> { + return fs.readFileString(path).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(DockerConfigSchema))), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); +} + +function readDockerContext( + fs: FileSystem.FileSystem, + path: string, +): Effect.Effect<Option.Option<Schema.Schema.Type<typeof DockerContextSchema>>> { + return fs.readFileString(path).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(DockerContextSchema))), + Effect.map(Option.some), + Effect.orElseSucceed(() => Option.none()), + ); +} + +function readHostEnvironment(): Effect.Effect<HostEnvironment, Config.ConfigError, LegacyViperEnv> { + const fallback: HostEnvironment = { + servicesHostname: undefined, + dockerHost: undefined, + dockerContext: undefined, + dockerConfig: undefined, + home: undefined, + }; + return Effect.gen(function* () { + const env = yield* LegacyViperEnv; + const runtimeInfo = yield* Effect.serviceOption(RuntimeInfo); + const values = yield* Effect.all({ + servicesHostname: env.get("SUPABASE_SERVICES_HOSTNAME"), + dockerHost: env.get("DOCKER_HOST"), + dockerContext: env.get("DOCKER_CONTEXT"), + dockerConfig: env.get("DOCKER_CONFIG"), + home: env.get("HOME"), + userProfile: env.get("USERPROFILE"), + }); + const home = Option.getOrUndefined(values.home); + const userProfile = Option.getOrUndefined(values.userProfile); + const runtimeHome = Option.match(runtimeInfo, { + onNone: () => undefined, + onSome: (value) => value.homeDir, + }); + return { + servicesHostname: Option.getOrUndefined(values.servicesHostname), + dockerHost: Option.getOrUndefined(values.dockerHost), + dockerContext: Option.getOrUndefined(values.dockerContext), + dockerConfig: Option.getOrUndefined(values.dockerConfig), + home: + home !== undefined && home.length > 0 + ? home + : userProfile !== undefined && userProfile.length > 0 + ? userProfile + : runtimeHome !== undefined && runtimeHome.length > 0 + ? runtimeHome + : undefined, }; - if (typeof config.currentContext === "string" && config.currentContext.length > 0) { - return config.currentContext; - } - } catch { - // Missing/malformed config.json → the default context, same as Go's own - // silent fallback when it can't load the config file here. + }).pipe(Effect.orElseSucceed(() => fallback)); +} + +function dockerConfigDir(env: HostEnvironment, path: Path.Path): string | undefined { + if (env.dockerConfig !== undefined && env.dockerConfig.length > 0) { + return env.dockerConfig; } - return DEFAULT_CONTEXT_NAME; + return env.home === undefined ? undefined : path.join(env.home, ".docker"); } -/** - * Reads a non-default context's daemon endpoint from Docker CLI's context - * store: `<configDir>/contexts/meta/<sha256hex(name)>/meta.json`'s - * `Endpoints.docker.Host` (`docker/cli` `cli/context/store/metadatastore.go`). - * The `"default"` context has no store entry (it's Go's synthetic - * always-available context, resolved without a client, see - * `cli.Initialize`), so it's never looked up here — matching the earlier - * `"default"` short-circuit in {@link currentDockerContextName}'s caller. - */ -function dockerContextEndpointHost(contextName: string): string | undefined { +function currentDockerContextName( + env: HostEnvironment, + fs: FileSystem.FileSystem, + path: Path.Path, +): Effect.Effect<string> { + if (env.dockerContext !== undefined && env.dockerContext.length > 0) { + return Effect.succeed(env.dockerContext); + } + const configDir = dockerConfigDir(env, path); + if (configDir === undefined) { + return Effect.void.pipe(Effect.as(DEFAULT_CONTEXT_NAME)); + } + return readDockerConfig(fs, path.join(configDir, "config.json")).pipe( + Effect.map((config) => + Option.isSome(config) && + config.value.currentContext !== undefined && + config.value.currentContext.length > 0 + ? config.value.currentContext + : DEFAULT_CONTEXT_NAME, + ), + ); +} + +function dockerContextEndpointHost( + contextName: string, + env: HostEnvironment, + crypto: Crypto.Crypto, + fs: FileSystem.FileSystem, + path: Path.Path, +): Effect.Effect<string | undefined, PlatformError.PlatformError> { if (contextName === DEFAULT_CONTEXT_NAME) { - return undefined; + return Effect.void.pipe(Effect.as(undefined)); } - try { - const contextId = createHash("sha256").update(contextName).digest("hex"); - const metaPath = join(dockerConfigDir(), "contexts", "meta", contextId, "meta.json"); - const meta = JSON.parse(readFileSync(metaPath, "utf8")) as { - readonly Endpoints?: { readonly docker?: { readonly Host?: unknown } }; - }; - const host = meta.Endpoints?.docker?.Host; - return typeof host === "string" && host.length > 0 ? host : undefined; - } catch { - // Missing/malformed context store entry → treat as unresolvable, same as - // Go silently falling back to the loopback default below. - return undefined; + const configDir = dockerConfigDir(env, path); + if (configDir === undefined) { + return Effect.void.pipe(Effect.as(undefined)); } + return crypto.digest("SHA-256", new TextEncoder().encode(contextName)).pipe( + Effect.map(Encoding.encodeHex), + Effect.flatMap((contextId) => { + const contextPath = path.join(configDir, "contexts", "meta", contextId, "meta.json"); + return readDockerContext(fs, contextPath); + }), + Effect.map((meta) => (Option.isSome(meta) ? meta.value.Endpoints?.docker?.Host : undefined)), + ); } -/** - * Extracts the bare host from a `tcp://host:port` daemon endpoint, mirroring - * `client.ParseHostURL` + `net.SplitHostPort`. Returns - * `undefined` for a non-`tcp://` endpoint (e.g. `unix://`, `npipe://`) or an - * unparseable one, in which case the caller falls back to the loopback - * default, matching `net.SplitHostPort` failure/non-TCP handling. - */ function hostFromTcpEndpoint(endpoint: string): string | undefined { try { const url = new URL(endpoint); if (url.protocol !== "tcp:" || url.hostname.length === 0) { return undefined; } - // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's - // `net.SplitHostPort` returns the bare host (`::1`). Strip a - // single surrounding bracket pair so local-stack probes dial/compare the - // same host Go does; IPv4 and named hosts are returned unchanged. const host = url.hostname; return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; } catch { @@ -101,51 +163,32 @@ function hostFromTcpEndpoint(endpoint: string): string | undefined { } /** - * Resolves the hostname used for local Supabase service connections, mirroring - * `utils.GetHostname`: - * - * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when - * the Docker daemon is not reachable on the container's own loopback. - * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint - * (`Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a - * `tcp://` one — `Docker.DaemonHost()` comes from a client built via - * `command.NewDockerCli()` + `cli.Initialize()`, whose endpoint resolution walks `DOCKER_HOST` -> - * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context - * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ - * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` - * binary this module's callers shell out to for `ps`/`inspect` already - * resolves the same active context itself, so without this step `status` - * could correctly inspect a remote daemon while printing unusable - * `127.0.0.1` API/DB/Studio URLs for it. - * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an - * unresolvable/malformed context). + * Resolves the hostname used for local Supabase service connections. * - * Shared across legacy commands that connect to the local stack (`gen types`, - * `test db`, `status`, `stop`, and later `db reset` / `db dump`). + * The resolver mirrors Go's `utils.GetHostname`: an explicit + * `SUPABASE_SERVICES_HOSTNAME` wins, followed by a TCP `DOCKER_HOST`, then the + * active Docker context's TCP endpoint, and finally loopback. Environment and + * filesystem access are injected so concurrent commands and tests do not + * mutate or snapshot process globals. */ -export function legacyGetHostname(): string { - const override = process.env["SUPABASE_SERVICES_HOSTNAME"]; - if (override !== undefined && override.length > 0) { - return override; +const legacyGetHostnameEffect = Effect.gen(function* () { + const env = yield* readHostEnvironment(); + if (env.servicesHostname !== undefined && env.servicesHostname.length > 0) { + return env.servicesHostname; } - const dockerHost = process.env["DOCKER_HOST"]; - if (dockerHost !== undefined && dockerHost.length > 0) { - return hostFromTcpEndpoint(dockerHost) ?? LOCAL_HOST; + if (env.dockerHost !== undefined && env.dockerHost.length > 0) { + return hostFromTcpEndpoint(env.dockerHost) ?? LOCAL_HOST; } - const contextEndpoint = dockerContextEndpointHost(currentDockerContextName()); - if (contextEndpoint !== undefined) { - const host = hostFromTcpEndpoint(contextEndpoint); - if (host !== undefined) { - return host; - } - } - return LOCAL_HOST; -} + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const contextName = yield* currentDockerContextName(env, fs, path); + const contextEndpoint = yield* dockerContextEndpointHost(contextName, env, crypto, fs, path); + return contextEndpoint === undefined + ? LOCAL_HOST + : (hostFromTcpEndpoint(contextEndpoint) ?? LOCAL_HOST); +}); -/** Keeps Bun from proxying the legacy CLI's loopback HTTP requests. */ -export function legacyConfigureLoopbackProxyBypass(env: NodeJS.ProcessEnv = process.env): void { - const key = (env["no_proxy"]?.length ?? 0) > 0 ? "no_proxy" : "NO_PROXY"; - const current = env[key]; - env[key] = current ? `${current},${LOOPBACK_NO_PROXY}` : LOOPBACK_NO_PROXY; -} +export const legacyGetHostname = legacyGetHostnameEffect.pipe( + Effect.orElseSucceed(() => LOCAL_HOST), +); diff --git a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts index 88656f8b2f..10bf87d466 100644 --- a/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-hostname.unit.test.ts @@ -1,218 +1,196 @@ -import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; - -import { legacyConfigureLoopbackProxyBypass, legacyGetHostname } from "./legacy-hostname.ts"; - -const LOOPBACK_NO_PROXY = "localhost,127.0.0.1,[::1]"; - -function withEnv<T>(entries: Record<string, string | undefined>, run: () => T): T { - const previous: Record<string, string | undefined> = {}; - for (const [key, value] of Object.entries(entries)) { - previous[key] = process.env[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - try { - return run(); - } finally { - for (const [key, value] of Object.entries(previous)) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } -} +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Crypto, Effect, Encoding, FileSystem, Layer, Path } from "effect"; + +import { legacyGetHostname } from "./legacy-hostname.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; +import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; -/** Writes a Docker CLI-shaped `$DOCKER_CONFIG` directory (`config.json` + a context store entry). */ -function writeDockerConfigDir(options: { +interface DockerFixture { readonly currentContext?: string; - readonly contexts?: Readonly<Record<string, string>>; // context name -> docker.Host endpoint -}): string { - const dir = mkdtempSync(join(tmpdir(), "legacy-hostname-docker-config-")); - if (options.currentContext !== undefined) { - writeFileSync( - join(dir, "config.json"), - JSON.stringify({ currentContext: options.currentContext }), - ); - } - for (const [name, host] of Object.entries(options.contexts ?? {})) { - const contextId = createHash("sha256").update(name).digest("hex"); - const metaDir = join(dir, "contexts", "meta", contextId); - mkdirSync(metaDir, { recursive: true }); - writeFileSync( - join(metaDir, "meta.json"), - JSON.stringify({ Endpoints: { docker: { Host: host } } }), + readonly contexts?: Readonly<Record<string, string>>; +} + +function runHostname( + env: Readonly<Record<string, string | undefined>>, + fixture?: DockerFixture, + homeVariable?: "HOME" | "USERPROFILE", + useRuntimeHomeFallback = false, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + return yield* Effect.acquireUseRelease( + fs.makeTempDirectory({ prefix: "legacy-hostname-docker-config-" }), + (dir) => { + const providerEnv: Record<string, string> = {}; + const fixtureEnv = + fixture === undefined + ? env + : homeVariable === undefined + ? useRuntimeHomeFallback + ? { ...env, DOCKER_CONFIG: undefined } + : { ...env, DOCKER_CONFIG: dir } + : { ...env, [homeVariable]: dir, DOCKER_CONFIG: undefined }; + const configDir = + fixture === undefined || (!useRuntimeHomeFallback && homeVariable === undefined) + ? dir + : path.join(dir, ".docker"); + for (const [key, value] of Object.entries(fixtureEnv)) { + if (value !== undefined) providerEnv[key] = value; + } + const envLayer = makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ + env: providerEnv, + preserveEmptyStrings: true, + }), + ); + return Effect.gen(function* () { + yield* fs.makeDirectory(configDir, { recursive: true }); + if (fixture?.currentContext !== undefined) { + yield* fs.writeFileString( + path.join(configDir, "config.json"), + `{"currentContext":"${fixture.currentContext}"}`, + ); + } + for (const [name, host] of Object.entries(fixture?.contexts ?? {})) { + const contextId = Encoding.encodeHex( + yield* crypto.digest("SHA-256", new TextEncoder().encode(name)), + ); + const metaDir = path.join(configDir, "contexts", "meta", contextId); + yield* fs.makeDirectory(metaDir, { recursive: true }); + yield* fs.writeFileString( + path.join(metaDir, "meta.json"), + `{"Endpoints":{"docker":{"Host":"${host}"}}}`, + ); + } + return yield* legacyGetHostname; + }).pipe( + Effect.provide( + Layer.mergeAll( + envLayer, + Layer.succeed( + RuntimeInfo, + RuntimeInfo.of({ + cwd: dir, + platform: "linux", + arch: "arm64", + homeDir: dir, + execPath: "/test/supabase", + pid: 1, + }), + ), + ), + ), + ); + }, + (dir) => fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore), ); - } - return dir; + }).pipe(Effect.provide(BunServices.layer)); } describe("legacyGetHostname", () => { - it("prefers SUPABASE_SERVICES_HOSTNAME over everything else", () => { - expect( - withEnv( - { SUPABASE_SERVICES_HOSTNAME: "db.internal", DOCKER_HOST: "tcp://docker:2375" }, - legacyGetHostname, - ), - ).toBe("db.internal"); - }); - - it("derives the host from a tcp:// DOCKER_HOST when no override is set", () => { - expect( - withEnv( - { SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: "tcp://docker-host:2375" }, - legacyGetHostname, - ), - ).toBe("docker-host"); - }); - - it("strips the brackets from an IPv6 tcp:// DOCKER_HOST (net.SplitHostPort parity)", () => { - // WHATWG URL.hostname returns `[::1]`; Go's net.SplitHostPort returns the bare - // `::1`, which is what gets dialed/compared, so the brackets must be stripped. - expect( - withEnv( - { SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: "tcp://[::1]:2375" }, - legacyGetHostname, - ), - ).toBe("::1"); - }); - - it("falls back to 127.0.0.1 for a unix-socket DOCKER_HOST", () => { - expect( - withEnv( - { SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: "unix:///var/run/docker.sock" }, - legacyGetHostname, - ), - ).toBe("127.0.0.1"); - }); - - it("falls back to 127.0.0.1 when neither env var is set", () => { - expect( - withEnv({ SUPABASE_SERVICES_HOSTNAME: undefined, DOCKER_HOST: undefined }, legacyGetHostname), - ).toBe("127.0.0.1"); - }); - - describe("active Docker context resolution (Go's Docker.DaemonHost() parity)", () => { - let configDirs: Array<string> = []; - - afterEach(() => { - for (const dir of configDirs) rmSync(dir, { recursive: true, force: true }); - configDirs = []; - }); - - function withDockerConfig<T>( - options: Parameters<typeof writeDockerConfigDir>[0], - env: Record<string, string | undefined>, - run: () => T, - ): T { - const dir = writeDockerConfigDir(options); - configDirs.push(dir); - return withEnv( - { - SUPABASE_SERVICES_HOSTNAME: undefined, - DOCKER_HOST: undefined, - DOCKER_CONFIG: dir, - ...env, - }, - run, - ); - } - - it("resolves the host from the active context's tcp:// endpoint via config.json's currentContext", () => { - const result = withDockerConfig( - { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, - {}, - legacyGetHostname, - ); - expect(result).toBe("remote-host"); - }); - - it("prefers DOCKER_CONTEXT over config.json's currentContext", () => { - const result = withDockerConfig( - { - currentContext: "other", - contexts: { envctx: "tcp://envctx-host:2375", other: "tcp://other-host:2375" }, - }, - { DOCKER_CONTEXT: "envctx" }, - legacyGetHostname, - ); - expect(result).toBe("envctx-host"); - }); - - it("strips brackets from an IPv6 context endpoint (net.SplitHostPort parity)", () => { - const result = withDockerConfig( - { currentContext: "remote", contexts: { remote: "tcp://[::1]:2375" } }, - {}, - legacyGetHostname, - ); - expect(result).toBe("::1"); - }); - - it("falls back to 127.0.0.1 when the active context's endpoint is not tcp://", () => { - const result = withDockerConfig( - { currentContext: "remote", contexts: { remote: "unix:///var/run/docker.sock" } }, - {}, - legacyGetHostname, - ); - expect(result).toBe("127.0.0.1"); - }); - - it("falls back to 127.0.0.1 when the context store entry is missing", () => { - const result = withDockerConfig({ currentContext: "ghost" }, {}, legacyGetHostname); - expect(result).toBe("127.0.0.1"); - }); - - it("falls back to 127.0.0.1 when config.json is missing entirely (default context)", () => { - const result = withDockerConfig({}, {}, legacyGetHostname); - expect(result).toBe("127.0.0.1"); - }); - - it("never consults the context store for the default context", () => { - const result = withDockerConfig( - { currentContext: "default", contexts: { default: "tcp://should-never-be-read:2375" } }, - {}, - legacyGetHostname, - ); - expect(result).toBe("127.0.0.1"); - }); - - it("DOCKER_HOST still takes precedence over an active non-default context", () => { - const result = withDockerConfig( - { currentContext: "remote", contexts: { remote: "tcp://context-host:2375" } }, - { DOCKER_HOST: "tcp://direct-host:2375" }, - legacyGetHostname, - ); - expect(result).toBe("direct-host"); - }); - }); -}); - -describe("legacyConfigureLoopbackProxyBypass", () => { - it.each([ - ["sets NO_PROXY when neither spelling is configured", {}, { NO_PROXY: LOOPBACK_NO_PROXY }], - [ - "preserves an existing NO_PROXY value", - { NO_PROXY: "example.com" }, - { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}` }, - ], - [ - "updates the non-empty lowercase value preferred by Bun", - { NO_PROXY: "uppercase.example", no_proxy: "lowercase.example" }, + it.effect("prefers SUPABASE_SERVICES_HOSTNAME over everything else", () => + runHostname({ + SUPABASE_SERVICES_HOSTNAME: "db.internal", + DOCKER_HOST: "tcp://docker:2375", + }).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("db.internal")))), + ); + + it.effect("derives the host from a tcp:// DOCKER_HOST when no override is set", () => + runHostname({ DOCKER_HOST: "tcp://docker-host:2375" }).pipe( + Effect.tap((host) => Effect.sync(() => expect(host).toBe("docker-host"))), + ), + ); + + it.effect("strips brackets from an IPv6 tcp:// DOCKER_HOST", () => + runHostname({ DOCKER_HOST: "tcp://[::1]:2375" }).pipe( + Effect.tap((host) => Effect.sync(() => expect(host).toBe("::1"))), + ), + ); + + it.effect("falls back to 127.0.0.1 for a unix-socket DOCKER_HOST", () => + runHostname({ DOCKER_HOST: "unix:///var/run/docker.sock" }).pipe( + Effect.tap((host) => Effect.sync(() => expect(host).toBe("127.0.0.1"))), + ), + ); + + it.effect("falls back to 127.0.0.1 when neither env var is set", () => + runHostname({}).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("127.0.0.1")))), + ); + + it.effect("resolves the active context's tcp endpoint", () => + runHostname( + {}, + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("remote-host")))), + ); + + it.effect("uses USERPROFILE for Docker config discovery when HOME is absent", () => + runHostname( + {}, + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + "USERPROFILE", + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("remote-host")))), + ); + + it.effect("uses RuntimeInfo.homeDir for Docker config discovery when env homes are absent", () => + runHostname( + {}, + { currentContext: "remote", contexts: { remote: "tcp://remote-host:2375" } }, + undefined, + true, + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("remote-host")))), + ); + + it.effect("prefers DOCKER_CONTEXT over config.json's currentContext", () => + runHostname( + { DOCKER_CONTEXT: "envctx" }, { - NO_PROXY: "uppercase.example", - no_proxy: `lowercase.example,${LOOPBACK_NO_PROXY}`, + currentContext: "other", + contexts: { + envctx: "tcp://envctx-host:2375", + other: "tcp://other-host:2375", + }, }, - ], - [ - "falls back to NO_PROXY when lowercase no_proxy is empty", - { NO_PROXY: "example.com", no_proxy: "" }, - { NO_PROXY: `example.com,${LOOPBACK_NO_PROXY}`, no_proxy: "" }, - ], - ])("%s", (_name, env, expected) => { - legacyConfigureLoopbackProxyBypass(env); - - expect(env).toEqual(expected); - }); + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("envctx-host")))), + ); + + it.effect("strips brackets from an IPv6 context endpoint", () => + runHostname({}, { currentContext: "remote", contexts: { remote: "tcp://[::1]:2375" } }).pipe( + Effect.tap((host) => Effect.sync(() => expect(host).toBe("::1"))), + ), + ); + + it.effect("falls back when the active context endpoint is not tcp://", () => + runHostname( + {}, + { currentContext: "remote", contexts: { remote: "unix:///var/run/docker.sock" } }, + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("127.0.0.1")))), + ); + + it.effect("falls back when the context store entry is missing", () => + runHostname({}, { currentContext: "ghost" }).pipe( + Effect.tap((host) => Effect.sync(() => expect(host).toBe("127.0.0.1"))), + ), + ); + + it.effect("falls back when config.json is missing", () => + runHostname({}).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("127.0.0.1")))), + ); + + it.effect("does not consult the context store for the default context", () => + runHostname( + {}, + { currentContext: "default", contexts: { default: "tcp://should-never-be-read:2375" } }, + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("127.0.0.1")))), + ); + + it.effect("prefers DOCKER_HOST over an active non-default context", () => + runHostname( + { DOCKER_HOST: "tcp://direct-host:2375" }, + { currentContext: "remote", contexts: { remote: "tcp://context-host:2375" } }, + ).pipe(Effect.tap((host) => Effect.sync(() => expect(host).toBe("direct-host")))), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-http-dns.ts b/apps/cli/src/legacy/shared/legacy-http-dns.ts index b804f5ed9d..2eb200bf33 100644 --- a/apps/cli/src/legacy/shared/legacy-http-dns.ts +++ b/apps/cli/src/legacy/shared/legacy-http-dns.ts @@ -103,57 +103,63 @@ export interface LegacyDohFetchOptions { export function legacyDohFetch(opts: LegacyDohFetchOptions): typeof globalThis.fetch { const { dnsResolver, resolver = legacyResolveHostsOverHttps } = opts; const innerFetch: FetchFn = opts.innerFetch ?? globalThis.fetch; - - const fetchImpl: FetchFn = async ( + const request = (input: string | URL | Request, init?: RequestInit) => + Effect.tryPromise({ + try: () => innerFetch(input, init), + catch: (cause) => + new LegacyDbConnectError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + const fetchImpl: FetchFn = ( input: string | URL | Request, init?: RequestInit, - ): Promise<Response> => { - // Normalise to string URL — same as what FetchHttpClient passes. - const originalUrl = - typeof input === "string" ? input : input instanceof URL ? input.href : input.url; - const parsed = new URL(originalUrl); - // URL.hostname returns bracketed IPv6 (e.g. "[::1]") in Bun. Strip brackets - // before the isIP check so net.isIP correctly identifies IPv6 literals. - const rawHostname = parsed.hostname; - const host = - rawHostname.startsWith("[") && rawHostname.endsWith("]") - ? rawHostname.slice(1, -1) - : rawHostname; - - // Short-circuit 1: not DoH mode or already an IP literal. - if (dnsResolver !== "https" || net.isIP(host) !== 0) { - return innerFetch(input, init); - } - - // DoH-resolve and take the first IP (Go's ip[0]). - const ips = await Effect.runPromise(resolver(host)); - const firstIp = ips[0]; - if (firstIp === undefined) { - // resolver guarantees non-empty; this is a safety net. - return innerFetch(input, init); - } - - const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp); - - // `BunFetchRequestInit` is Bun's global fetch-init type; it extends the - // standard `RequestInit` with `tls`. Bun's fetch honors `tls.serverName`: - // the TLS handshake sends this as the SNI extension and validates the peer - // certificate against it — not against the IP in the URL. Evidence: - // `fetch('https://104.16.133.229/', { tls: { serverName: 'cloudflare.com' } })` - // returned HTTP 403 (cert validated OK against 'cloudflare.com'). CWE-350 - // guard: cert validation never falls back to the raw IP even though the URL - // authority is an IP literal. - const rewrittenInit: BunFetchRequestInit = { - ...init, - headers: { - ...init?.headers, - Host: hostHeader, - }, - tls: { serverName }, - }; - - return innerFetch(url, rewrittenInit); - }; + ): Promise<Response> => + Effect.runPromise( + Effect.gen(function* () { + // Normalise to string URL — same as what FetchHttpClient passes. + const originalUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const parsed = new URL(originalUrl); + // URL.hostname returns bracketed IPv6 (e.g. "[::1]") in Bun. Strip brackets + // before the isIP check so net.isIP correctly identifies IPv6 literals. + const rawHostname = parsed.hostname; + const host = + rawHostname.startsWith("[") && rawHostname.endsWith("]") + ? rawHostname.slice(1, -1) + : rawHostname; + + // Short-circuit 1: not DoH mode or already an IP literal. + if (dnsResolver !== "https" || net.isIP(host) !== 0) { + return yield* request(input, init); + } + + // DoH-resolve and take the first IP (Go's ip[0]). + const ips = yield* resolver(host); + const firstIp = ips[0]; + if (firstIp === undefined) { + // resolver guarantees non-empty; this is a safety net. + return yield* request(input, init); + } + + const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp); + + // `BunFetchRequestInit` is Bun's global fetch-init type; it extends the + // standard `RequestInit` with `tls`. Bun's fetch honors `tls.serverName`: + // the TLS handshake sends this as the SNI extension and validates the peer + // certificate against it — not against the IP in the URL. + const headers = new Headers(init?.headers); + headers.set("Host", hostHeader); + const rewrittenInit: BunFetchRequestInit = { + ...init, + headers, + tls: { serverName }, + }; + + return yield* request(url, rewrittenInit); + }), + ); // `FetchHttpClient.Fetch` holds a `typeof globalThis.fetch`, which in Bun // carries a `preconnect` namespace member alongside the call signature. diff --git a/apps/cli/src/legacy/shared/legacy-http-dns.unit.test.ts b/apps/cli/src/legacy/shared/legacy-http-dns.unit.test.ts index 3dd2959484..9f3fbc297e 100644 --- a/apps/cli/src/legacy/shared/legacy-http-dns.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-http-dns.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Exit, Layer } from "effect"; import * as net from "node:net"; import { LegacyDnsResolverFlag } from "../../shared/legacy/global-flags.ts"; @@ -59,10 +59,10 @@ describe("legacyDohFetch", () => { }; function makeFakeFetch(captured: CapturedCall[]): typeof globalThis.fetch { - const fn = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + const fn = (input: string | URL | Request, init?: RequestInit): Promise<Response> => { const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; captured.push({ url, init: (init ?? {}) as CapturedCall["init"] }); - return new Response("ok", { status: 200 }); + return Promise.resolve(new Response("ok", { status: 200 })); }; return fn as typeof globalThis.fetch; } @@ -71,98 +71,113 @@ describe("legacyDohFetch", () => { return (_host: string) => Effect.succeed(ips); } - it("dials the first resolved IP, sets tls.serverName, and injects Host header", async () => { - const captured: CapturedCall[] = []; - const fetchFn = legacyDohFetch({ - dnsResolver: "https", - resolver: makeFakeResolver(["203.0.113.10", "203.0.113.11"]), - innerFetch: makeFakeFetch(captured), - }); + it.effect("dials the first resolved IP, sets tls.serverName, and injects Host header", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const fetchFn = legacyDohFetch({ + dnsResolver: "https", + resolver: makeFakeResolver(["203.0.113.10", "203.0.113.11"]), + innerFetch: makeFakeFetch(captured), + }); - await fetchFn("https://api.supabase.com/v1/projects", { - method: "GET", - headers: { authorization: "Bearer tok" }, - }); + yield* Effect.promise(() => + fetchFn("https://api.supabase.com/v1/projects", { + method: "GET", + headers: { authorization: "Bearer tok" }, + }), + ); - expect(captured).toHaveLength(1); - const call = captured[0]!; - // URL authority is the first resolved IP. - expect(new URL(call.url).hostname).toBe("203.0.113.10"); - // Path preserved. - expect(new URL(call.url).pathname).toBe("/v1/projects"); - // TLS SNI set to original hostname (CWE-350 guard). - expect(call.init.tls?.serverName).toBe("api.supabase.com"); - // Host header pinned to original hostname. - const headers = call.init.headers as Record<string, string>; - expect(headers["Host"]).toBe("api.supabase.com"); - // Other headers preserved. - expect(headers["authorization"]).toBe("Bearer tok"); - }); + expect(captured).toHaveLength(1); + const call = captured[0]!; + // URL authority is the first resolved IP. + expect(new URL(call.url).hostname).toBe("203.0.113.10"); + // Path preserved. + expect(new URL(call.url).pathname).toBe("/v1/projects"); + // TLS SNI set to original hostname (CWE-350 guard). + expect(call.init.tls?.serverName).toBe("api.supabase.com"); + // Host header pinned to original hostname. + const headers = new Headers(call.init.headers); + expect(headers.get("Host")).toBe("api.supabase.com"); + // Other headers preserved. + expect(headers.get("authorization")).toBe("Bearer tok"); + }), + ); - it("passes through without DoH when dnsResolver is 'native'", async () => { - const captured: CapturedCall[] = []; - const resolverCalls: string[] = []; - const fetchFn = legacyDohFetch({ - dnsResolver: "native", - resolver: (host) => { - resolverCalls.push(host); - return Effect.succeed(["203.0.113.10"]); - }, - innerFetch: makeFakeFetch(captured), - }); + it.effect("passes through without DoH when dnsResolver is 'native'", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const resolverCalls: string[] = []; + const fetchFn = legacyDohFetch({ + dnsResolver: "native", + resolver: (host) => { + resolverCalls.push(host); + return Effect.succeed(["203.0.113.10"]); + }, + innerFetch: makeFakeFetch(captured), + }); - await fetchFn("https://api.supabase.com/v1/projects"); + yield* Effect.promise(() => fetchFn("https://api.supabase.com/v1/projects")); - // Original URL passed through unchanged. - expect(captured[0]?.url).toBe("https://api.supabase.com/v1/projects"); - expect(resolverCalls).toHaveLength(0); - }); + // Original URL passed through unchanged. + expect(captured[0]?.url).toBe("https://api.supabase.com/v1/projects"); + expect(resolverCalls).toHaveLength(0); + }), + ); - it("passes through without DoH when the URL host is already an IPv4 literal", async () => { - const captured: CapturedCall[] = []; - const resolverCalls: string[] = []; - const fetchFn = legacyDohFetch({ - dnsResolver: "https", - resolver: (host) => { - resolverCalls.push(host); - return Effect.succeed(["203.0.113.10"]); - }, - innerFetch: makeFakeFetch(captured), - }); + it.effect("passes through without DoH when the URL host is already an IPv4 literal", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const resolverCalls: string[] = []; + const fetchFn = legacyDohFetch({ + dnsResolver: "https", + resolver: (host) => { + resolverCalls.push(host); + return Effect.succeed(["203.0.113.10"]); + }, + innerFetch: makeFakeFetch(captured), + }); - await fetchFn("https://203.0.113.99/v1/projects"); + yield* Effect.promise(() => fetchFn("https://203.0.113.99/v1/projects")); - expect(captured[0]?.url).toBe("https://203.0.113.99/v1/projects"); - expect(resolverCalls).toHaveLength(0); - }); + expect(captured[0]?.url).toBe("https://203.0.113.99/v1/projects"); + expect(resolverCalls).toHaveLength(0); + }), + ); - it("passes through without DoH when the URL host is already an IPv6 literal", async () => { - const captured: CapturedCall[] = []; - const resolverCalls: string[] = []; - const fetchFn = legacyDohFetch({ - dnsResolver: "https", - resolver: (host) => { - resolverCalls.push(host); - return Effect.succeed(["2001:db8::1"]); - }, - innerFetch: makeFakeFetch(captured), - }); + it.effect("passes through without DoH when the URL host is already an IPv6 literal", () => + Effect.gen(function* () { + const captured: CapturedCall[] = []; + const resolverCalls: string[] = []; + const fetchFn = legacyDohFetch({ + dnsResolver: "https", + resolver: (host) => { + resolverCalls.push(host); + return Effect.succeed(["2001:db8::1"]); + }, + innerFetch: makeFakeFetch(captured), + }); - await fetchFn("https://[2001:db8::1]/v1/projects"); + yield* Effect.promise(() => fetchFn("https://[2001:db8::1]/v1/projects")); - expect(captured[0]?.url).toBe("https://[2001:db8::1]/v1/projects"); - expect(resolverCalls).toHaveLength(0); - }); + expect(captured[0]?.url).toBe("https://[2001:db8::1]/v1/projects"); + expect(resolverCalls).toHaveLength(0); + }), + ); - it("propagates resolver failures as rejected promises", async () => { - const fetchFn = legacyDohFetch({ - dnsResolver: "https", - resolver: (_host) => Effect.fail(new LegacyDbConnectError({ message: "DoH timed out" })), - innerFetch: makeFakeFetch([]), - }); + it.effect("propagates resolver failures as rejected promises", () => + Effect.gen(function* () { + const fetchFn = legacyDohFetch({ + dnsResolver: "https", + resolver: (_host) => Effect.fail(new LegacyDbConnectError({ message: "DoH timed out" })), + innerFetch: makeFakeFetch([]), + }); - await expect(fetchFn("https://api.supabase.com/v1/projects")).rejects.toThrow(); - }); + const exit = yield* Effect.promise(() => + fetchFn("https://api.supabase.com/v1/projects"), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); }); describe("legacyDohFetchLayer (Effect layer integration)", () => { @@ -172,7 +187,7 @@ describe("legacyDohFetchLayer (Effect layer integration)", () => { const fakeFetch = legacyDohFetch({ dnsResolver: "https", resolver: (_host) => Effect.succeed(["203.0.113.10"]), - innerFetch: (async (input: string | URL | Request, init?: RequestInit) => { + innerFetch: (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input @@ -180,8 +195,8 @@ describe("legacyDohFetchLayer (Effect layer integration)", () => { ? input.href : (input as Request).url; captured.push({ url, tls: (init as { tls?: { serverName: string } })?.tls }); - return new Response("ok", { status: 200 }); - }) as typeof globalThis.fetch, + return Promise.resolve(new Response("ok", { status: 200 })); + }, }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index b856e50235..11c0b0b6d4 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -94,7 +94,7 @@ export function mapLegacyHttpError<N, S>(opts: { // These failures occur while the generated client validates or builds // the request. Keep their identity because this generic mapper cannot // safely infer user provenance or reclassify them as response errors. - return yield* Effect.fail(cause); + return yield* cause; } if (HttpClientError.isHttpClientError(cause)) { if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { diff --git a/apps/cli/src/legacy/shared/legacy-identity-stitch.integration.test.ts b/apps/cli/src/legacy/shared/legacy-identity-stitch.integration.test.ts index c70753f9b1..db9fa30fe0 100644 --- a/apps/cli/src/legacy/shared/legacy-identity-stitch.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-identity-stitch.integration.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { mockAnalytics, mockTelemetryRuntime } from "../../../tests/helpers/mocks.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer } from "./legacy-identity-stitch.ts"; +const LegacyTelemetryFixtureSchema = Schema.fromJsonString( + Schema.Struct({ + enabled: Schema.Boolean, + device_id: Schema.String, + schema_version: Schema.optional(Schema.Finite), + }), +); + /** * Build a minimal fake HttpClientResponse carrying the given headers. */ @@ -44,17 +52,19 @@ function makeStitchLayer(opts: { describe("legacyIdentityStitchLayer — stitchedDistinctId()", () => { it.live("populates stitchedDistinctId() after the first response with X-Gotrue-Id", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/legacy-identity-stitch-test-" + String(Date.now()); + const configDir = "/tmp/legacy-identity-stitch-test-first"; return Effect.gen(function* () { // Write a valid telemetry.json so stitchIdentity sees enabled=true. const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(configDir, { recursive: true }); - yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify({ enabled: true, device_id: "device-001", schema_version: 1 }), - ); + const telemetry = yield* Schema.encodeEffect(LegacyTelemetryFixtureSchema)({ + enabled: true, + device_id: "device-001", + schema_version: 1, + }); + yield* fs.writeFileString(path.join(configDir, "telemetry.json"), telemetry); const svc = yield* LegacyIdentityStitch; @@ -71,24 +81,30 @@ describe("legacyIdentityStitchLayer — stitchedDistinctId()", () => { expect(analytics.aliased).toHaveLength(1); expect(analytics.aliased[0]).toEqual({ distinctId: "gotrue-abc-123", alias: "device-001" }); }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), + Effect.provide( + Layer.mergeAll( + makeStitchLayer({ analytics, configDir }), + BunFileSystem.layer, + BunPath.layer, + ), + ), ); }); it.live("once-only guard: a second stitch call with a different id keeps the first", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/legacy-identity-stitch-test-guard-" + String(Date.now()); + const configDir = "/tmp/legacy-identity-stitch-test-guard"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(configDir, { recursive: true }); - yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify({ enabled: true, device_id: "device-001", schema_version: 1 }), - ); + const telemetry = yield* Schema.encodeEffect(LegacyTelemetryFixtureSchema)({ + enabled: true, + device_id: "device-001", + schema_version: 1, + }); + yield* fs.writeFileString(path.join(configDir, "telemetry.json"), telemetry); const svc = yield* LegacyIdentityStitch; @@ -102,9 +118,13 @@ describe("legacyIdentityStitchLayer — stitchedDistinctId()", () => { expect(analytics.aliased).toHaveLength(1); expect(analytics.aliased[0]?.distinctId).toBe("first-id"); }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), + Effect.provide( + Layer.mergeAll( + makeStitchLayer({ analytics, configDir }), + BunFileSystem.layer, + BunPath.layer, + ), + ), ); }); }); @@ -112,7 +132,7 @@ describe("legacyIdentityStitchLayer — stitchedDistinctId()", () => { describe("legacyIdentityStitchLayer — hybrid stamp/alias", () => { it.live("ephemeral (CI) runtime stamps the identity but does not alias or persist", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/legacy-identity-stitch-test-ci-" + String(Date.now()); + const configDir = "/tmp/legacy-identity-stitch-test-ci"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -129,15 +149,19 @@ describe("legacyIdentityStitchLayer — hybrid stamp/alias", () => { const exists = yield* fs.exists(path.join(configDir, "telemetry.json")); expect(exists).toBe(false); }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir, isCi: true })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), + Effect.provide( + Layer.mergeAll( + makeStitchLayer({ analytics, configDir, isCi: true }), + BunFileSystem.layer, + BunPath.layer, + ), + ), ); }); it.live("stamps over a stale persisted identity without aliasing", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/legacy-identity-stitch-test-stale-" + String(Date.now()); + const configDir = "/tmp/legacy-identity-stitch-test-stale"; return Effect.gen(function* () { const svc = yield* LegacyIdentityStitch; @@ -151,24 +175,30 @@ describe("legacyIdentityStitchLayer — hybrid stamp/alias", () => { // ...but we never alias — that would merge two unrelated person graphs. expect(analytics.aliased).toHaveLength(0); }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir, distinctId: "old-user" })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), + Effect.provide( + Layer.mergeAll( + makeStitchLayer({ analytics, configDir, distinctId: "old-user" }), + BunFileSystem.layer, + BunPath.layer, + ), + ), ); }); it.live("concurrent first responses alias exactly once", () => { const analytics = mockAnalytics(); - const configDir = "/tmp/legacy-identity-stitch-test-conc-" + String(Date.now()); + const configDir = "/tmp/legacy-identity-stitch-test-concurrent"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(configDir, { recursive: true }); - yield* fs.writeFileString( - path.join(configDir, "telemetry.json"), - JSON.stringify({ enabled: true, device_id: "device-001", schema_version: 1 }), - ); + const telemetry = yield* Schema.encodeEffect(LegacyTelemetryFixtureSchema)({ + enabled: true, + device_id: "device-001", + schema_version: 1, + }); + yield* fs.writeFileString(path.join(configDir, "telemetry.json"), telemetry); const svc = yield* LegacyIdentityStitch; @@ -185,9 +215,13 @@ describe("legacyIdentityStitchLayer — hybrid stamp/alias", () => { expect(analytics.aliased).toHaveLength(1); expect(svc.stitchedDistinctId()).toBe(analytics.aliased[0]?.distinctId); }).pipe( - Effect.provide(makeStitchLayer({ analytics, configDir })), - Effect.provide(BunFileSystem.layer), - Effect.provide(BunPath.layer), + Effect.provide( + Layer.mergeAll( + makeStitchLayer({ analytics, configDir }), + BunFileSystem.layer, + BunPath.layer, + ), + ), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-identity-stitch.ts b/apps/cli/src/legacy/shared/legacy-identity-stitch.ts index 3db438c634..f1840fa8c5 100644 --- a/apps/cli/src/legacy/shared/legacy-identity-stitch.ts +++ b/apps/cli/src/legacy/shared/legacy-identity-stitch.ts @@ -1,4 +1,4 @@ -import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Clock, Context, DateTime, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; @@ -35,6 +35,17 @@ import { readExistingState } from "../telemetry/legacy-telemetry-state.layer.ts" const HEADER_GOTRUE_ID = "x-gotrue-id"; const TELEMETRY_SCHEMA_VERSION = 1; +const LegacyTelemetryStateJsonSchema = Schema.fromJsonString( + Schema.Struct({ + enabled: Schema.Boolean, + device_id: Schema.String, + session_id: Schema.String, + session_last_active: Schema.String, + distinct_id: Schema.String, + schema_version: Schema.optional(Schema.Unknown), + }), +); + interface LegacyTelemetryState { readonly enabled: boolean; readonly device_id: string; @@ -108,9 +119,9 @@ const makeLegacyIdentityStitcher: Effect.Effect< // `LoadOrCreateState` already decoded, it never re-parses the file itself. // This also fixes a prior bug where a `consent: "denied"` file (no // `enabled` key) was treated as `enabled: true`. - const prior = Option.match(existing, { - onNone: () => undefined, - onSome: readExistingState, + const prior = yield* Option.match(existing, { + onNone: () => Effect.void, + onSome: (content) => Effect.succeed(readExistingState(content)), }); const enabled = prior?.enabled ?? true; if (!enabled) return; @@ -129,7 +140,9 @@ const makeLegacyIdentityStitcher: Effect.Effect< enabled, device_id: prior?.device_id ?? runtime.deviceId, session_id: prior?.session_id ?? runtime.sessionId, - session_last_active: new Date().toISOString(), + session_last_active: DateTime.formatIso( + DateTime.makeUnsafe(yield* Clock.currentTimeMillis), + ), distinct_id: gotrueId, schema_version: prior?.schemaVersionToken !== undefined @@ -138,16 +151,15 @@ const makeLegacyIdentityStitcher: Effect.Effect< }; yield* fs.makeDirectory(runtime.configDir, { recursive: true }); - yield* fs.writeFileString( - telemetryPath, + const encoded = yield* Schema.encodeEffect(LegacyTelemetryStateJsonSchema)( // Exact int64 token of the prior schema_version, when there is one: - // re-serializing `state.schema_version` directly would round tokens - // above 2^53 through `Number` (9007199254740993 → …992) — Go decodes - // and re-encodes the 64-bit `int` verbatim. + // retaining the raw JSON token avoids rounding values above 2^53 + // (9007199254740993 → …992) while preserving Go's 64-bit re-encoding. prior?.schemaVersionToken === undefined - ? JSON.stringify(state) - : JSON.stringify({ ...state, schema_version: JSON.rawJSON(prior.schemaVersionToken) }), + ? state + : { ...state, schema_version: JSON.rawJSON(prior.schemaVersionToken) }, ); + yield* fs.writeFileString(telemetryPath, encoded); }); const stitch = (response: HttpClientResponse.HttpClientResponse) => { diff --git a/apps/cli/src/legacy/shared/legacy-linked-state.ts b/apps/cli/src/legacy/shared/legacy-linked-state.ts index aaba37a4a2..854bbad60a 100644 --- a/apps/cli/src/legacy/shared/legacy-linked-state.ts +++ b/apps/cli/src/legacy/shared/legacy-linked-state.ts @@ -108,7 +108,7 @@ const legacyAcquireLinkedStateApi = Effect.fnUntraced(function* () { return yield* factoryOption.value.make.pipe( Effect.map(Option.some), - Effect.catch(() => Effect.succeed(Option.none<ApiClient>())), + Effect.orElseSucceed(() => Option.none<ApiClient>()), ); }); @@ -154,7 +154,7 @@ const legacyFindLinkedBranchName = Effect.fnUntraced(function* ( Effect.timeout(LEGACY_LINKED_STATE_LOOKUP_TIMEOUT), // Best-effort: any transport/status/decode failure OR the timeout above // degrades below — this helper must never fail on a flaky/slow lookup. - Effect.catch(() => Effect.succeed(Option.none<LegacyLinkedStateBranches>())), + Effect.orElseSucceed(() => Option.none<LegacyLinkedStateBranches>()), ); return Option.isSome(branchesOption) diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 0f9ec61346..81632188de 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1,9 +1,7 @@ -import { readFileSync } from "node:fs"; -import { basename } from "node:path"; - import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; -import { Schema } from "effect"; +import { Clock, Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import { resolveRemoteJwks, @@ -35,15 +33,14 @@ import { type LegacyConfigValidationInput, type LegacyDbInput, legacyEmailContentPathReadErrorMessage, + legacyResolveApiTlsPath, + legacyResolveSigningKeysPath, type LegacyExperimentalInput, type LegacyHookInput, type LegacyLocalSmtpInput, type LegacyMfaFactorInput, legacyParseGoBool, type LegacyPasskeyInput, - legacyResolveApiTlsPath, - legacyResolveEmailTemplateContentPath, - legacyResolveSigningKeysPath, legacySigningKeysDecodeErrorMessage, legacySigningKeysReadErrorMessage, type LegacySmtpInput, @@ -181,11 +178,12 @@ function apiUrlWithPath(apiExternalUrl: string, path: string): string { * any command renders output, so no local dev stack can even start with a * short secret. */ -export class LegacyInvalidJwtSecretError extends Error { +export class LegacyInvalidJwtSecretError extends Data.TaggedError("LegacyInvalidJwtSecretError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidJwtSecretError"; constructor() { - super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); - this.name = "LegacyInvalidJwtSecretError"; + super({ message: "Invalid config for auth.jwt_secret. Must be at least 16 characters" }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; @@ -207,11 +205,12 @@ const MIN_JWT_SECRET_LENGTH = 16; * internal error (that's viper/mapstructure library text, not a Go-authored * string), but the parity-relevant part — hard-fail, same field name — is. */ -export class LegacyInvalidPortEnvOverrideError extends Error { +export class LegacyInvalidPortEnvOverrideError extends Data.TaggedError( + "LegacyInvalidPortEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidPortEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); - this.name = "LegacyInvalidPortEnvOverrideError"; + super({ message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port` }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; @@ -258,30 +257,29 @@ export function legacyEnvOverridePort( * is treated as unset. * * The env-override binding is resolved AFTER the project's `supabase/.env`(.local) - * and project-root dotenv files are loaded into the process env — so a value - * that lives only in one of those files, not the ambient shell, must still be - * visible here. `projectEnvValues` is that already-resolved map (see - * `legacyResolveProjectEnvironmentValues`); falling back to `process.env` - * covers the "no `supabase/` project found" case, where `projectEnvValues` is - * `undefined`. + * and project-root dotenv files are loaded into the resolved environment map — + * so a value that lives only in one of those files, not the ambient shell, must + * still be visible here. `projectEnvValues` is that already-resolved map (see + * `legacyResolveProjectEnvironmentValues`) and is required at this boundary; + * callers without a project file pass the explicitly-resolved empty map. * * The resolved override string itself can be a further `env(VAR)` indirection * (e.g. `SUPABASE_API_ENABLED=env(API_ENABLED)`), resolved on every string * decoded into the config regardless of whether it came from `config.toml` or - * a `SUPABASE_*` override. Resolved with the same `projectEnvValues ?? - * process.env` precedence and non-empty gate as the outer lookup; an - * unresolved/empty indirection leaves the `env(VAR)` literal untouched. + * a `SUPABASE_*` override. Resolved with the same project-environment + * precedence and non-empty gate as the outer lookup; an unresolved/empty + * indirection leaves the `env(VAR)` literal untouched. */ export function legacyEnvOverride( name: string, configured: string | undefined, projectEnvValues: Readonly<Record<string, string>> | undefined, ): string | undefined { - const value = projectEnvValues?.[name] ?? process.env[name]; + const value = projectEnvValues?.[name]; if (value === undefined || value.length === 0) return configured; const indirection = ENV_CAPTURE_REGEX.exec(value)?.[1]; if (indirection === undefined) return value; - const resolved = projectEnvValues?.[indirection] ?? process.env[indirection]; + const resolved = projectEnvValues?.[indirection]; return resolved !== undefined && resolved.length > 0 ? resolved : value; } @@ -296,11 +294,12 @@ export function legacyEnvOverride( * loading on a bad value — there is no Go code path that reaches `status`/ * `stop` with a malformed bool override. */ -export class LegacyInvalidBoolEnvOverrideError extends Error { +export class LegacyInvalidBoolEnvOverrideError extends Data.TaggedError( + "LegacyInvalidBoolEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidBoolEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); - this.name = "LegacyInvalidBoolEnvOverrideError"; + super({ message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool` }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; @@ -350,14 +349,15 @@ export function legacyEnvOverrideBool( * loading outright, same mechanism as {@link LegacyInvalidPortEnvOverrideError}/ * {@link LegacyInvalidBoolEnvOverrideError}. */ -export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { +export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Data.TaggedError( + "LegacyInvalidAnalyticsBackendEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidAnalyticsBackendEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, - ); - this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; @@ -410,14 +410,15 @@ function envOverrideAnalyticsBackend( * outside `{IPv4, IPv6}`, same mechanism as * {@link LegacyInvalidAnalyticsBackendEnvOverrideError}. */ -export class LegacyInvalidRealtimeIpVersionEnvOverrideError extends Error { +export class LegacyInvalidRealtimeIpVersionEnvOverrideError extends Data.TaggedError( + "LegacyInvalidRealtimeIpVersionEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidRealtimeIpVersionEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, - ); - this.name = "LegacyInvalidRealtimeIpVersionEnvOverrideError"; + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, + }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -475,13 +476,14 @@ export function legacyEnvOverrideApiMaxRows( * hard-rejects anything outside `{transaction, session}`, same mechanism as * {@link LegacyInvalidRealtimeIpVersionEnvOverrideError}. */ -export class LegacyInvalidPoolModeEnvOverrideError extends Error { +export class LegacyInvalidPoolModeEnvOverrideError extends Data.TaggedError( + "LegacyInvalidPoolModeEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidPoolModeEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, - ); - this.name = "LegacyInvalidPoolModeEnvOverrideError"; + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, + }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -514,14 +516,15 @@ export function legacyEnvOverridePoolMode( * outside `{per_worker, oneshot}`, same mechanism as * {@link LegacyInvalidPoolModeEnvOverrideError}. */ -export class LegacyInvalidEdgeRuntimePolicyEnvOverrideError extends Error { +export class LegacyInvalidEdgeRuntimePolicyEnvOverrideError extends Data.TaggedError( + "LegacyInvalidEdgeRuntimePolicyEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidEdgeRuntimePolicyEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, - ); - this.name = "LegacyInvalidEdgeRuntimePolicyEnvOverrideError"; + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, + }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -825,34 +828,42 @@ function resolveSignedKey( jwtSecret: string, signingKey: LegacyJwk | undefined, role: "anon" | "service_role", + nowSeconds: number, ): string { if (configured !== undefined && configured.length > 0) return configured; return signingKey !== undefined - ? legacyGenerateAsymmetricGoJwt(signingKey, role) + ? legacyGenerateAsymmetricGoJwt(signingKey, role, nowSeconds) : legacyGenerateGoJwt(jwtSecret, role); } /** Matches `JWK` struct fields — see `LegacyJwk`. */ const LegacyJwkSchema = Schema.Struct({ kty: Schema.String, - kid: Schema.optionalKey(Schema.String), - use: Schema.optionalKey(Schema.String), - key_ops: Schema.optionalKey(Schema.Array(Schema.String)), - alg: Schema.optionalKey(Schema.String), - ext: Schema.optionalKey(Schema.Boolean), - n: Schema.optionalKey(Schema.String), - e: Schema.optionalKey(Schema.String), - d: Schema.optionalKey(Schema.String), - p: Schema.optionalKey(Schema.String), - q: Schema.optionalKey(Schema.String), - dp: Schema.optionalKey(Schema.String), - dq: Schema.optionalKey(Schema.String), - qi: Schema.optionalKey(Schema.String), - crv: Schema.optionalKey(Schema.String), - x: Schema.optionalKey(Schema.String), - y: Schema.optionalKey(Schema.String), + kid: Schema.optional(Schema.String), + use: Schema.optional(Schema.String), + key_ops: Schema.optional(Schema.Array(Schema.String)), + alg: Schema.optional(Schema.String), + ext: Schema.optional(Schema.Boolean), + n: Schema.optional(Schema.String), + e: Schema.optional(Schema.String), + d: Schema.optional(Schema.String), + p: Schema.optional(Schema.String), + q: Schema.optional(Schema.String), + dp: Schema.optional(Schema.String), + dq: Schema.optional(Schema.String), + qi: Schema.optional(Schema.String), + crv: Schema.optional(Schema.String), + x: Schema.optional(Schema.String), + y: Schema.optional(Schema.String), + k: Schema.optional(Schema.String), }); -const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)); +const decodeLegacyJwks = Schema.decodeSync(Schema.fromJsonString(Schema.Array(LegacyJwkSchema))); +// Local signing keys are decoded with `LegacyJwkSchema` above, but remote JWKS +// documents must retain provider-specific extension members (`x5c`, `x5t`, +// custom claims, etc.) when the final document is emitted. +const encodeLegacyJwksDocument = Schema.encodeSync( + Schema.fromJsonString(Schema.Struct({ keys: Schema.Array(Schema.Unknown) })), +); /** * `Config.Validate`: a relative @@ -863,16 +874,13 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * {@link resolveSignedKey}) and {@link legacyResolveLocalJwks} (the full array, matching * `ResolveJWKS`'s `a.SigningKeys` loop). * - * Uses `node:fs` directly (not the `FileSystem` Effect service other Go-parity - * resolvers in `legacy/` use for file reads) so this function — and its large - * existing test surface — can stay a plain synchronous resolver; this is an - * optional, rarely-configured field, not worth threading Effect dependencies - * through `legacyStatusValues`/`status.handler.ts` for. + * Uses the Effect `FileSystem` and `Path` services so callers can provide the + * same scoped runtime in commands and tests without ambient filesystem access. * * Error wording matches Go's two `Validate` failure branches exactly * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ - * `JSON.parse`'s raw Node error text through unwrapped. + * platform error text through unwrapped. * * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- * overridden value, not necessarily raw `config.auth.enabled` — see @@ -881,32 +889,49 @@ const decodeLegacyJwks = Schema.decodeUnknownSync(Schema.Array(LegacyJwkSchema)) * that same post-override value, so a disabled auth section never touches * `signing_keys_path`, however stale or missing that file is. */ -function readSigningKeysFile(workdir: string, signingKeysPath: string): ReadonlyArray<LegacyJwk> { - const absolutePath = legacyResolveSigningKeysPath(workdir, signingKeysPath); - - let contents: string; - try { - contents = readFileSync(absolutePath, "utf8"); - } catch (cause) { - throw new LegacyConfigValidateError(legacySigningKeysReadErrorMessage(cause)); - } - - try { - // `Schema.Array` decodes `key_ops` as `ReadonlyArray<string>`, but `LegacyJwk.key_ops` is a - // mutable `string[]` (required for assignability into Node's `createPrivateKey`/`JsonWebKey` - // input — see that type's own doc comment), so each key's `key_ops` is copied into a fresh - // mutable array here rather than widening the schema's own (correctly readonly) output type. - return decodeLegacyJwks(JSON.parse(contents)).map((jwk) => ({ - ...jwk, - key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], - })); - } catch (cause) { - throw new LegacyConfigValidateError(legacySigningKeysDecodeErrorMessage(cause)); - } +function readSigningKeysFile( + workdir: string, + signingKeysPath: string, +): Effect.Effect< + ReadonlyArray<LegacyJwk>, + LegacyConfigValidateError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = legacyResolveSigningKeysPath(path, workdir, signingKeysPath); + const contents = yield* fs + .readFileString(absolutePath) + .pipe( + Effect.mapError( + (cause) => new LegacyConfigValidateError(legacySigningKeysReadErrorMessage(cause)), + ), + ); + return yield* Effect.try({ + try: () => + // `Schema.Array` decodes `key_ops` as `ReadonlyArray<string>`, but `LegacyJwk.key_ops` is a + // mutable `string[]` (required for assignability into Node's `createPrivateKey`/`JsonWebKey` + // input — see that type's own doc comment), so each key's `key_ops` is copied into a fresh + // mutable array here rather than widening the schema's own (correctly readonly) output type. + decodeLegacyJwks(contents).map((jwk) => ({ + ...jwk, + key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], + })), + catch: (cause) => new LegacyConfigValidateError(legacySigningKeysDecodeErrorMessage(cause)), + }); + }); } /** See {@link readSigningKeysFile}. */ -function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArray<LegacyJwk> { +function loadSigningKeys( + workdir: string, + signingKeysPath: string, +): Effect.Effect< + ReadonlyArray<LegacyJwk>, + LegacyConfigValidateError, + FileSystem.FileSystem | Path.Path +> { return readSigningKeysFile(workdir, signingKeysPath); } @@ -945,26 +970,32 @@ export function legacyResolveConfiguredSigningKeys( workdir: string, projectEnvValues: Readonly<Record<string, string>> | undefined, remoteOverrideKeys: ReadonlySet<string> = new Set(), -): ReadonlyArray<LegacyJwk> | undefined { - const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 - ? loadSigningKeys(workdir, signingKeysPath) - : undefined; +): Effect.Effect< + ReadonlyArray<LegacyJwk> | undefined, + LegacyConfigValidateError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); + return authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0 + ? yield* loadSigningKeys(workdir, signingKeysPath) + : undefined; + }); } /** @@ -986,28 +1017,34 @@ export function legacyResolveConfiguredSigningKeys( * validation already ported for `seed buckets`/`storage` in * `legacy-storage-credentials.ts`'s `validateLocalKongTls`. * - * Uses `node:fs` directly for the same reason as {@link readSigningKeysFile}: this stays a plain - * synchronous resolver rather than threading the Effect `FileSystem` service through - * `legacyStatusValues`/`status.handler.ts`. + * Uses the Effect `FileSystem` and `Path` services to keep reads scoped to the + * owning command runtime and deterministic in tests. */ function readApiTlsFiles( workdir: string, certPath: string | undefined, keyPath: string | undefined, -): void { - if (certPath === undefined || certPath.length === 0) return; - if (keyPath === undefined || keyPath.length === 0) return; - - try { - readFileSync(legacyResolveApiTlsPath(workdir, certPath), "utf8"); - } catch (cause) { - throw new LegacyConfigValidateError(legacyApiTlsCertReadErrorMessage(cause)); - } - try { - readFileSync(legacyResolveApiTlsPath(workdir, keyPath), "utf8"); - } catch (cause) { - throw new LegacyConfigValidateError(legacyApiTlsKeyReadErrorMessage(cause)); - } +): Effect.Effect<void, LegacyConfigValidateError, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + if (certPath === undefined || certPath.length === 0) return; + if (keyPath === undefined || keyPath.length === 0) return; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs + .readFileString(legacyResolveApiTlsPath(path, workdir, certPath)) + .pipe( + Effect.mapError( + (cause) => new LegacyConfigValidateError(legacyApiTlsCertReadErrorMessage(cause)), + ), + ); + yield* fs + .readFileString(legacyResolveApiTlsPath(path, workdir, keyPath)) + .pipe( + Effect.mapError( + (cause) => new LegacyConfigValidateError(legacyApiTlsKeyReadErrorMessage(cause)), + ), + ); + }); } /** @@ -1228,42 +1265,92 @@ export function legacyResolveAuthEmail( * only performs the file-existence read, matching the other validators' "resolve once, validate * the resolved value" shape. */ -function readAuthEmailTemplateContent(email: LegacyResolvedAuthEmail, workdir: string): void { - for (const [name, tmpl] of Object.entries(email.template)) { - const path = legacyResolveEmailTemplateContentPath({ - section: "template", - name, - contentPath: tmpl.content_path, - contentPresent: tmpl.content_present, - base: workdir, - }); - if (path === undefined) continue; - try { - readFileSync(path, "utf8"); - } catch (cause) { - throw new LegacyConfigValidateError( - legacyEmailContentPathReadErrorMessage("template", name, cause), - ); +function resolveEmailTemplateContentPathEffect(args: { + readonly section: "template" | "notification"; + readonly name: string; + readonly contentPath: string; + readonly contentPresent: boolean; + readonly base: string; +}): Effect.Effect< + string | undefined, + LegacyConfigValidateError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + if (args.contentPath.length === 0) { + if (args.contentPresent) { + return yield* new LegacyConfigValidateError( + `Invalid config for auth.email.${args.section}.${args.name}.content: please use content_path instead`, + ); + } + return undefined; } - } - for (const [name, tmpl] of Object.entries(email.notification)) { - if (!tmpl.enabled) continue; - const path = legacyResolveEmailTemplateContentPath({ - section: "notification", - name, - contentPath: tmpl.content_path, - contentPresent: tmpl.content_present, - base: workdir, - }); - if (path === undefined) continue; - try { - readFileSync(path, "utf8"); - } catch (cause) { - throw new LegacyConfigValidateError( - legacyEmailContentPathReadErrorMessage("notification", name, cause), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if (args.section === "template" || path.isAbsolute(args.contentPath)) { + return path.isAbsolute(args.contentPath) + ? args.contentPath + : path.join(args.base, args.contentPath); } - } + const rootResolved = path.join(args.base, args.contentPath); + const rootStat = yield* fs.stat(rootResolved).pipe(Effect.option); + if (Option.isSome(rootStat) && rootStat.value.type === "File") return rootResolved; + const legacyResolved = path.join(args.base, "supabase", args.contentPath); + const legacyStat = yield* fs.stat(legacyResolved).pipe(Effect.option); + return Option.isSome(legacyStat) && legacyStat.value.type === "File" + ? legacyResolved + : rootResolved; + }); +} + +function readAuthEmailTemplateContent( + email: LegacyResolvedAuthEmail, + workdir: string, +): Effect.Effect<void, LegacyConfigValidateError, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const [name, tmpl] of Object.entries(email.template)) { + const contentPath = yield* resolveEmailTemplateContentPathEffect({ + section: "template", + name, + contentPath: tmpl.content_path, + contentPresent: tmpl.content_present, + base: workdir, + }); + if (contentPath === undefined) continue; + yield* fs + .readFileString(contentPath) + .pipe( + Effect.mapError( + (cause) => + new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("template", name, cause), + ), + ), + ); + } + for (const [name, tmpl] of Object.entries(email.notification)) { + if (!tmpl.enabled) continue; + const contentPath = yield* resolveEmailTemplateContentPathEffect({ + section: "notification", + name, + contentPath: tmpl.content_path, + contentPresent: tmpl.content_present, + base: workdir, + }); + if (contentPath === undefined) continue; + yield* fs + .readFileString(contentPath) + .pipe( + Effect.mapError( + (cause) => + new LegacyConfigValidateError( + legacyEmailContentPathReadErrorMessage("notification", name, cause), + ), + ), + ); + } + }); } // Decode-time overflow bound for every field routed through {@link legacyEnvOverrideUint}: @@ -1342,11 +1429,24 @@ export function legacyEnvOverrideUint( if (value === undefined) return configured; const parsed = parseGoBaseZeroUint(value); if (parsed === undefined || parsed > LEGACY_UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); + throw new LegacyInvalidUintEnvOverrideError(dottedFieldPath, value); } return Number(parsed); } +/** Typed config-load failure for a malformed unsigned integer env override. */ +export class LegacyInvalidUintEnvOverrideError extends Data.TaggedError( + "LegacyInvalidUintEnvOverrideError", +)<{ readonly message: string }> { + static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidUintEnvOverrideError"; + constructor(dottedFieldPath: string, value: string) { + super({ message: `Failed reading config: Invalid ${dottedFieldPath}: ${value}.` }); + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** `SUPABASE_DB_MAJOR_VERSION` — see {@link legacyEnvOverrideUint}. */ export function legacyEnvOverrideMajorVersion( configured: number, @@ -1395,7 +1495,7 @@ function envOverrideOptionalUint( if (value === undefined) return configured; const parsed = parseGoBaseZeroUint(value); if (parsed === undefined || parsed > LEGACY_UINT_MAX) { - throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); + throw new LegacyInvalidUintEnvOverrideError(dottedFieldPath, value); } return Number(parsed); } @@ -1429,14 +1529,15 @@ function legacyEnvOverrideOptionalBool( * {@link LegacyInvalidAnalyticsBackendEnvOverrideError}/ * {@link LegacyInvalidRealtimeIpVersionEnvOverrideError}. */ -export class LegacyInvalidSessionReplicationRoleEnvOverrideError extends Error { +export class LegacyInvalidSessionReplicationRoleEnvOverrideError extends Data.TaggedError( + "LegacyInvalidSessionReplicationRoleEnvOverrideError", +)<{ readonly message: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyInvalidSessionReplicationRoleEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, - ); - this.name = "LegacyInvalidSessionReplicationRoleEnvOverrideError"; + super({ + message: `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, + }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -1694,11 +1795,25 @@ export function legacyEnvOverrideAuthPasswordRequirements( projectEnvValues, ); if (override !== undefined && !LEGACY_PASSWORD_REQUIREMENTS_VALUES.has(override)) { - throw new Error(`Failed reading config: Invalid auth.password_requirements: ${override}.`); + throw new LegacyInvalidAuthPasswordRequirementsEnvOverrideError(override); } return override ?? configured; } +/** Typed config-load failure for an invalid auth.password_requirements override. */ +export class LegacyInvalidAuthPasswordRequirementsEnvOverrideError extends Data.TaggedError( + "LegacyInvalidAuthPasswordRequirementsEnvOverrideError", +)<{ readonly message: string }> { + static readonly [ErrorActionabilityFingerprintId] = + "LegacyInvalidAuthPasswordRequirementsEnvOverrideError"; + constructor(value: string) { + super({ message: `Failed reading config: Invalid auth.password_requirements: ${value}.` }); + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + /** Narrows an unknown value to a plain object, mirroring `legacy-db-config.toml-read.ts`'s `asRecord`. */ function asRecord(value: unknown): Record<string, unknown> | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -2775,7 +2890,10 @@ export function legacyRawUnmodeledBool(value: unknown, dottedFieldPath: string): } return parsed; } - throw new LegacyInvalidBoolEnvOverrideError(dottedFieldPath, String(value)); + throw new LegacyInvalidBoolEnvOverrideError( + dottedFieldPath, + Array.isArray(value) ? value.join(",") : Object.prototype.toString.call(value), + ); } /** @@ -3017,7 +3135,7 @@ export function legacyResolveLocalConfigValues( config: ProjectConfig, hostname: string, workdir: string, - projectEnvValues: Readonly<Record<string, string>> | undefined = undefined, + projectEnvValues: Readonly<Record<string, string>>, /** * `LoadedProjectConfig.document` (`packages/config/src/io.ts`) — the raw, * pre-schema-default TOML document `config` was decoded from. Lets checks @@ -3028,7 +3146,7 @@ export function legacyResolveLocalConfigValues( * existing unit tests); those checks are then simply skipped rather than * guessed at. */ - document: Readonly<Record<string, unknown>> | undefined = undefined, + document?: Readonly<Record<string, unknown>>, /** * Config keys a matched `[remotes.<ref>]` block contributed at override tier (applied ABOVE * the ambient env tier) — see @@ -3108,919 +3226,973 @@ export function legacyResolveLocalConfigValues( * as before this parameter existed. */ projectIdFallback?: string, -): LegacyLocalConfigValues { - const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); - // `Config.Validate` checks `ProjectId` FIRST, before every other field — - // see this function's `@throws` doc above - // for why a workdir basename that sanitizes to `""` fails here even when - // `project_id` is absent from the file entirely. `config.project_id` is - // `undefined` only when the key is genuinely absent (`optionalKey`, see - // `packages/config/src/base.ts`) — that's the ONE case where Go's own - // sanitized-basename-or-`projectIdFallback` viper default shows through - // instead of a file value, so the fallback belongs here, not as a third - // branch after `legacyEnvOverride`. - // `SUPABASE_PROJECT_ID` is checked via the same `legacyEnvOverride` precedence - // every other field here uses, since Viper's `AutomaticEnv` binds it too - // and it can turn an explicit-empty file value (or an - // unsanitizable basename fallback) back into a valid override. Deliberately NOT - // gated by `remoteWins("project_id")` (unlike the fields below): the ONLY consumer - // of this value is `legacyValidateResolvedConfig`'s emptiness check - // (`legacy-config-validate.ts:336`), and `legacyEnvOverride` (a plain, non-throwing - // string read) can never turn an already non-empty remote-merged `project_id` into - // an empty one, nor vice versa — so gating here would change no observable - // accept/reject outcome. The real "shadow's network id/labels resolve the wrong - // project id" bug this pattern otherwise guards against lives in - // `legacy-local-project-context.ts`'s OWN, separately-consumed project id (see its - // doc comment — review: PRRT_kwDOErm0O86XHGDL), not this validation-only field. - const resolvedProjectId = legacyEnvOverride( - "SUPABASE_PROJECT_ID", - config.project_id ?? - (projectIdFallback !== undefined && projectIdFallback.length > 0 - ? projectIdFallback - : legacySanitizeProjectId(basename(workdir))), - projectEnvValues, - ); - - // `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` - // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ - // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override, - // so the values fed into - // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- - // `scheme://host:port` derivation (which picks `https` vs `http` from - // `tls.enabled`) must be the overridden ones too. - // A matched remote block's `api.tls.enabled` was installed at viper's OVERRIDE tier (above - // `AutomaticEnv`), so it must win over a conflicting `SUPABASE_API_TLS_ENABLED` — this field - // reaches `apiUrl`/`restUrl`/etc, which the shadow's own `db diff --linked`/`db pull` setup - // input consumes (`legacyBuildLocalDbContainerInputs`). - const apiTlsEnabled = remoteWins("api.tls.enabled") - ? config.api.tls.enabled - : legacyEnvOverrideBool( - "SUPABASE_API_TLS_ENABLED", - config.api.tls.enabled, - "api.tls.enabled", - projectEnvValues, - ); - // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` — - // mirroring `authEnabled` below, gate on the - // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. - // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above and `authEnabled` below — - // `api.enabled` is now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) - // and that reader's own resolver already gates it (`legacyBlockProvidesKey(block, - // "api.enabled")`); this resolver must match, since an ungated `legacyEnvOverrideBool` call - // THROWS on a malformed `SUPABASE_API_ENABLED` even when a matched remote block already set - // `api.enabled` at viper's OVERRIDE tier — a value `Validate` never even evaluates the - // env var for in that case — which would otherwise abort this whole function (and the shadow - // it feeds via `legacyBuildLocalDbContainerInputs`, denying it `apiPort`/`apiUrl`/`dbPort`/ - // `rootKey`/etc.) on an env value the override tier should silently ignore. `apiEnabled`'s own resolved value is - // never part of the returned `LegacyLocalConfigValues` — same "throws before caller-needed - // fields are resolved" rationale as `authEnabled`/`analytics.*`/`edge_runtime.deno_version` - // below, not the "value is consumed downstream" rationale `apiTlsEnabled`/`apiPort` above have. - const apiEnabled = remoteWins("api.enabled") - ? config.api.enabled - : legacyEnvOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above: a matched remote - // block's `api.tls.cert_path`/`key_path` were installed at viper's OVERRIDE tier (above - // `AutomaticEnv`), so they must win over a conflicting `SUPABASE_API_TLS_CERT_PATH`/ - // `SUPABASE_API_TLS_KEY_PATH` — otherwise a stale/missing ambient env path can fail - // `readApiTlsFiles` below even though the remote block already supplied a valid path - // Go would actually use (review: PRRT_kwDOErm0O86W8ZYk). - const apiTlsCertPath = remoteWins("api.tls.cert_path") - ? config.api.tls.cert_path - : legacyEnvOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); - const apiTlsKeyPath = remoteWins("api.tls.key_path") - ? config.api.tls.key_path - : legacyEnvOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); - if (apiEnabled && apiTlsEnabled) { - readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); - } - // `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY - // when `api.enabled` — unlike `db.port` - // below, which has no `enabled` gate. Resolved once into a named const so the - // check and the URL derivation below share the same overridden value instead - // of calling `legacyEnvOverridePort` twice. - // Same remote-over-env precedence as `apiTlsEnabled` above. - const apiPort = remoteWins("api.port") - ? config.api.port - : legacyEnvOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); - const apiExternalUrl = legacyResolveApiExternalUrl( - { - // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above. - external_url: remoteWins("api.external_url") - ? config.api.external_url - : legacyEnvOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), - port: apiPort, - tls: { enabled: apiTlsEnabled }, - }, - hostname, - ); - // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no - // `enabled` gate in `Config.Validate` — it's unconditionally required, - // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this - // exact message before `status`/`stop` - // render anything, same wording already used for the `db query`/`test db` - // path (`legacy-db-config.toml-read.ts:1380`). - // Same remote-over-env precedence as `apiPort`/`apiTlsEnabled` above — `dbPort` also reaches - // `dbUrl`, consumed by the shadow's own `db diff --linked`/`db pull` setup input. - const dbPort = remoteWins("db.port") - ? config.db.port - : legacyEnvOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); - // `Config.Validate` checks `db.major_version` right after `db.port`, - // unconditionally (no `enabled` gate). Validate-only here - // (this function's return type has no `majorVersion` field — the shadow's own resolved value - // comes from `legacyResolveDbBootstrapConfig`, which already gates it) — but a matched - // remote's `db.major_version` must still suppress a conflicting `SUPABASE_DB_MAJOR_VERSION` - // here too, otherwise a malformed env value the remote block should have made irrelevant - // fails this validate-only read outright before the (correctly gated) real value is ever - // reached (review: PRRT_kwDOErm0O86W2tRi). - const majorVersion = remoteWins("db.major_version") - ? config.db.major_version - : legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); - // Config load applies every `SUPABASE_DB_SETTINGS_*` override unconditionally, - // BEFORE `start`/`status`/`stop` do anything else — so a malformed override must fail here, the same - // point `majorVersion`/`denoVersion`/`orioledbVersion` are already validated, not deep inside - // `start.handler.ts`'s `bringUp` after Postgres may already be created. Validate-only: the - // actual resolved settings `start` needs are recomputed at their own call site (same - // "validate early, recompute at point of use" split already used for those three fields). - // `remoteOverrideKeys` threaded through so a matched remote's `db.settings.*` value doesn't - // fail this validate-only read the same way `majorVersion` above doesn't. - legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); - // Same gap for `db.network_restrictions.enabled` — `[db.network_restrictions]` ships - // uncommented in the default template (unlike the commented-out `[db.ssl_enforcement]`) and - // `network_restrictions` is a plain, always-registered field, so a malformed - // `SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED` override decodes - // unconditionally — same bucket as `db.port`/`db.major_version` above, not - // the presence-gated `db.ssl_enforcement`/`auth.sms.twilio`/`auth.external.apple` cases. - // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). Same - // remote-over-env precedence as `majorVersion` above. - if (!remoteWins("db.network_restrictions.enabled")) { - legacyEnvOverrideBool( - "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", - config.db.network_restrictions.enabled, - "db.network_restrictions.enabled", +): Effect.Effect< + LegacyLocalConfigValues, + LegacyConfigValidateError | LegacyInvalidJwtSecretError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const path = yield* Path.Path; + const nowSeconds = Math.floor((yield* Clock.currentTimeMillis) / 1000); + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); + // `Config.Validate` checks `ProjectId` FIRST, before every other field — + // see this function's `@throws` doc above + // for why a workdir basename that sanitizes to `""` fails here even when + // `project_id` is absent from the file entirely. `config.project_id` is + // `undefined` only when the key is genuinely absent (`optionalKey`, see + // `packages/config/src/base.ts`) — that's the ONE case where Go's own + // sanitized-basename-or-`projectIdFallback` viper default shows through + // instead of a file value, so the fallback belongs here, not as a third + // branch after `legacyEnvOverride`. + // `SUPABASE_PROJECT_ID` is checked via the same `legacyEnvOverride` precedence + // every other field here uses, since Viper's `AutomaticEnv` binds it too + // and it can turn an explicit-empty file value (or an + // unsanitizable basename fallback) back into a valid override. Deliberately NOT + // gated by `remoteWins("project_id")` (unlike the fields below): the ONLY consumer + // of this value is `legacyValidateResolvedConfig`'s emptiness check + // (`legacy-config-validate.ts:336`), and `legacyEnvOverride` (a plain, non-throwing + // string read) can never turn an already non-empty remote-merged `project_id` into + // an empty one, nor vice versa — so gating here would change no observable + // accept/reject outcome. The real "shadow's network id/labels resolve the wrong + // project id" bug this pattern otherwise guards against lives in + // `legacy-local-project-context.ts`'s OWN, separately-consumed project id (see its + // doc comment — review: PRRT_kwDOErm0O86XHGDL), not this validation-only field. + const resolvedProjectId = legacyEnvOverride( + "SUPABASE_PROJECT_ID", + config.project_id ?? + (projectIdFallback !== undefined && projectIdFallback.length > 0 + ? projectIdFallback + : legacySanitizeProjectId(path.basename(workdir))), projectEnvValues, ); - } - // `db.root_key` isn't modeled in `@supabase/config`'s schema (every other - // `db.*` field is), so it's read off the raw pre-schema document — same - // presence-based pattern as `authDocument` below. The - // default-or-configured, decrypted-if-`encrypted:` value is written verbatim into - // `/etc/postgresql-custom/pgsodium_root.key` on every start, - // going through the same secret-decrypt step every other secret field gets. - const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; - // The secret decrypt step only intercepts a STRING source value; - // any other raw TOML kind (integer, bool, array, ...) - // falls through untouched, and decoding that scalar into a secret-shaped struct is - // then rejected with exactly this message — same "decoding failed due to the following - // error(s):" wrapper already used for `auth.captcha.provider`/`analytics.backend` above. - if (rawRootKeyValue !== undefined && typeof rawRootKeyValue !== "string") { - throw new LegacyConfigValidateError( - "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", + + // `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` + // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ + // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override, + // so the values fed into + // `legacyResolveApiExternalUrl`'s own `external_url`-wins-else- + // `scheme://host:port` derivation (which picks `https` vs `http` from + // `tls.enabled`) must be the overridden ones too. + // A matched remote block's `api.tls.enabled` was installed at viper's OVERRIDE tier (above + // `AutomaticEnv`), so it must win over a conflicting `SUPABASE_API_TLS_ENABLED` — this field + // reaches `apiUrl`/`restUrl`/etc, which the shadow's own `db diff --linked`/`db pull` setup + // input consumes (`legacyBuildLocalDbContainerInputs`). + const apiTlsEnabled = remoteWins("api.tls.enabled") + ? config.api.tls.enabled + : legacyEnvOverrideBool( + "SUPABASE_API_TLS_ENABLED", + config.api.tls.enabled, + "api.tls.enabled", + projectEnvValues, + ); + // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` — + // mirroring `authEnabled` below, gate on the + // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above and `authEnabled` below — + // `api.enabled` is now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) + // and that reader's own resolver already gates it (`legacyBlockProvidesKey(block, + // "api.enabled")`); this resolver must match, since an ungated `legacyEnvOverrideBool` call + // THROWS on a malformed `SUPABASE_API_ENABLED` even when a matched remote block already set + // `api.enabled` at viper's OVERRIDE tier — a value `Validate` never even evaluates the + // env var for in that case — which would otherwise abort this whole function (and the shadow + // it feeds via `legacyBuildLocalDbContainerInputs`, denying it `apiPort`/`apiUrl`/`dbPort`/ + // `rootKey`/etc.) on an env value the override tier should silently ignore. `apiEnabled`'s own resolved value is + // never part of the returned `LegacyLocalConfigValues` — same "throws before caller-needed + // fields are resolved" rationale as `authEnabled`/`analytics.*`/`edge_runtime.deno_version` + // below, not the "value is consumed downstream" rationale `apiTlsEnabled`/`apiPort` above have. + const apiEnabled = remoteWins("api.enabled") + ? config.api.enabled + : legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above: a matched remote + // block's `api.tls.cert_path`/`key_path` were installed at viper's OVERRIDE tier (above + // `AutomaticEnv`), so they must win over a conflicting `SUPABASE_API_TLS_CERT_PATH`/ + // `SUPABASE_API_TLS_KEY_PATH` — otherwise a stale/missing ambient env path can fail + // `readApiTlsFiles` below even though the remote block already supplied a valid path + // Go would actually use (review: PRRT_kwDOErm0O86W8ZYk). + const apiTlsCertPath = remoteWins("api.tls.cert_path") + ? config.api.tls.cert_path + : legacyEnvOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); + const apiTlsKeyPath = remoteWins("api.tls.key_path") + ? config.api.tls.key_path + : legacyEnvOverride("SUPABASE_API_TLS_KEY_PATH", config.api.tls.key_path, projectEnvValues); + if (apiEnabled && apiTlsEnabled) { + yield* readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); + } + // `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY + // when `api.enabled` — unlike `db.port` + // below, which has no `enabled` gate. Resolved once into a named const so the + // check and the URL derivation below share the same overridden value instead + // of calling `legacyEnvOverridePort` twice. + // Same remote-over-env precedence as `apiTlsEnabled` above. + const apiPort = remoteWins("api.port") + ? config.api.port + : legacyEnvOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); + const apiExternalUrl = legacyResolveApiExternalUrl( + { + // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above. + external_url: remoteWins("api.external_url") + ? config.api.external_url + : legacyEnvOverride( + "SUPABASE_API_EXTERNAL_URL", + config.api.external_url, + projectEnvValues, + ), + port: apiPort, + tls: { enabled: apiTlsEnabled }, + }, + hostname, ); - } - // Same remote-over-env precedence as `apiPort`/`dbPort` above — `rootKey` reaches the - // shadow's own Postgres container spec (`legacyBuildLocalDbContainerInputs`). `rawRootKeyValue` - // already reflects a matched remote's `db.root_key` (`document` is the remote-merged raw doc — - // see `LoadedProjectConfig.document`'s own doc comment), so `remoteWins` here just means - // "don't let a conflicting `SUPABASE_DB_ROOT_KEY` clobber that already-merged value." - const rawRootKey = remoteWins("db.root_key") - ? rawRootKeyValue - : legacyEnvOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); - const rootKey = - rawRootKey === undefined || rawRootKey.length === 0 - ? LEGACY_POSTGRES_DEFAULT_ROOT_KEY - : (legacyDecryptAuthSecret(rawRootKey, projectEnvValues) ?? LEGACY_POSTGRES_DEFAULT_ROOT_KEY); - // `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` - // key right after `db.major_version`, unconditionally. - const storageBucketNames = - config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; - // `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` - // ONLY when `studio.enabled` — same - // enabled-gated pattern as `api.port` above. - // Same remote-over-env precedence as `apiEnabled`/`apiPort` above — `studio.enabled`/ - // `studio.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`): - // an ungated `legacyEnvOverrideBool`/`legacyEnvOverridePort` call here THROWS on a malformed - // `SUPABASE_STUDIO_ENABLED`/`SUPABASE_STUDIO_PORT` even when a matched remote block already - // set that field at viper's OVERRIDE tier, which would abort this whole function — and the - // shadow it feeds — on an env value the override tier should silently ignore (review: PRRT_kwDOErm0O86W6R-G). - const studioEnabled = remoteWins("studio.enabled") - ? config.studio.enabled - : legacyEnvOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const studioPort = remoteWins("studio.port") - ? config.studio.port - : legacyEnvOverridePort( - "SUPABASE_STUDIO_PORT", - config.studio.port, - "studio.port", - projectEnvValues, - ); - // `Config.Validate` parses `studio.api_url` with `net/url.Parse` right - // after the port check, still inside `if c.Studio.Enabled`. - // `config.studio.api_url` is a required - // (defaulted) field, so `legacyEnvOverride` can only return `undefined` here if - // that default itself were somehow undefined — the `??` fallback just - // satisfies that generic signature. - // `legacyEnvOverride` itself never throws, but `studio.api_url` feeds - // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check below, which DOES throw on a - // malformed URL — same "non-throwing read, throwing downstream consumer" bug class already - // fixed for `legacyResolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XGTq5). An - // ungated read here can flip that validate() outcome even though nothing in this read itself - // throws, so `studio.api_url` is gated the same way as `studio.enabled`/`studio.port` above. - const studioApiUrl = remoteWins("studio.api_url") - ? config.studio.api_url - : (legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? - config.studio.api_url); - // `Config.Validate` rejects `local_smtp.port === 0`/ - // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct - // field is still named `Inbucket` for the `[local_smtp]` TOML section, - // so `local_smtp.enabled` and the - // deprecated `inbucket.enabled` alias are the same underlying flag, not two - // independent ones. - // Same remote-over-env precedence as `studioEnabled`/`studioPort` above — `local_smtp.enabled`/ - // `local_smtp.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` for the identical reason. - const mailpitEnabled = remoteWins("local_smtp.enabled") - ? config.local_smtp.enabled - : legacyEnvOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const mailpitPort = remoteWins("local_smtp.port") - ? config.local_smtp.port - : legacyEnvOverridePort( - "SUPABASE_LOCAL_SMTP_PORT", - config.local_smtp.port, - "local_smtp.port", - projectEnvValues, - ); - // Same remote-over-env precedence as `apiPort`/`dbPort`/`rootKey` above — `jwtSecret` reaches - // the shadow's own Postgres/fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`). - const jwtSecret = resolveJwtSecret( - legacyDecryptAuthSecret( - remoteWins("auth.jwt_secret") - ? config.auth.jwt_secret - : legacyEnvOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), - projectEnvValues, - ), - ); - // Same remote-over-env precedence as `jwtSecret` above — `signingKeysPath` gates whether - // {@link legacyResolveConfiguredSigningKeys} below produces an asymmetric `signingKey`, which - // feeds `anonKey`/`serviceRoleKey` (already remote-gated fields the shadow's setup consumes). - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - // Gated on `auth.enabled`: - // the signing-keys file read only runs when auth is enabled, so a - // disabled auth section never opens/parses `signing_keys_path`, even a stale - // or missing one. JWT-secret validation and anon/service_role key generation - // run unconditionally either way, so - // only this file read is gated. `auth.enabled` is itself env-bindable like - // any other field, so this gate reads the - // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence - // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. - // Same remote-over-env precedence as every other gated field above — `auth.enabled` IS in - // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and that reader's own - // resolver already gates it (`remoteOverrideKeys.has("auth.enabled")`); this resolver must - // match, since an ungated `legacyEnvOverrideBool` call THROWS on a malformed - // `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` at - // override tier — a value validation never even evaluates the env var for in - // that case — which would otherwise abort this whole function (and the shadow it feeds via - // `legacyBuildLocalDbContainerInputs`) on an env value the override tier should silently ignore - // (review: PRRT_kwDOErm0O86W30n6). - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", + // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no + // `enabled` gate in `Config.Validate` — it's unconditionally required, + // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this + // exact message before `status`/`stop` + // render anything, same wording already used for the `db query`/`test db` + // path (`legacy-db-config.toml-read.ts:1380`). + // Same remote-over-env precedence as `apiPort`/`apiTlsEnabled` above — `dbPort` also reaches + // `dbUrl`, consumed by the shadow's own `db diff --linked`/`db pull` setup input. + const dbPort = remoteWins("db.port") + ? config.db.port + : legacyEnvOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); + // `Config.Validate` checks `db.major_version` right after `db.port`, + // unconditionally (no `enabled` gate). Validate-only here + // (this function's return type has no `majorVersion` field — the shadow's own resolved value + // comes from `legacyResolveDbBootstrapConfig`, which already gates it) — but a matched + // remote's `db.major_version` must still suppress a conflicting `SUPABASE_DB_MAJOR_VERSION` + // here too, otherwise a malformed env value the remote block should have made irrelevant + // fails this validate-only read outright before the (correctly gated) real value is ever + // reached (review: PRRT_kwDOErm0O86W2tRi). + const majorVersion = remoteWins("db.major_version") + ? config.db.major_version + : legacyEnvOverrideMajorVersion(config.db.major_version, projectEnvValues); + // Config load applies every `SUPABASE_DB_SETTINGS_*` override unconditionally, + // BEFORE `start`/`status`/`stop` do anything else — so a malformed override must fail here, the same + // point `majorVersion`/`denoVersion`/`orioledbVersion` are already validated, not deep inside + // `start.handler.ts`'s `bringUp` after Postgres may already be created. Validate-only: the + // actual resolved settings `start` needs are recomputed at their own call site (same + // "validate early, recompute at point of use" split already used for those three fields). + // `remoteOverrideKeys` threaded through so a matched remote's `db.settings.*` value doesn't + // fail this validate-only read the same way `majorVersion` above doesn't. + legacyResolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); + // Same gap for `db.network_restrictions.enabled` — `[db.network_restrictions]` ships + // uncommented in the default template (unlike the commented-out `[db.ssl_enforcement]`) and + // `network_restrictions` is a plain, always-registered field, so a malformed + // `SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED` override decodes + // unconditionally — same bucket as `db.port`/`db.major_version` above, not + // the presence-gated `db.ssl_enforcement`/`auth.sms.twilio`/`auth.external.apple` cases. + // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). Same + // remote-over-env precedence as `majorVersion` above. + if (!remoteWins("db.network_restrictions.enabled")) { + legacyEnvOverrideBool( + "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", + config.db.network_restrictions.enabled, + "db.network_restrictions.enabled", projectEnvValues, ); - // `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled`, - // before the signing-keys read below — - // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT - // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as - // `""` with no schema-level error, same gap as `db.port === 0` above. - // Same remote-over-env precedence as `jwtSecret` above — `siteUrl` reaches the shadow's own - // fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`'s `authSiteUrl`). - const siteUrl = remoteWins("auth.site_url") - ? config.auth.site_url - : (legacyEnvOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? - config.auth.site_url); - // GoTrue's env is built straight off the resolved auth config, with no local - // override logic of its own — the override happens earlier, generically, so - // every flat `auth.*` scalar fed into - // GoTrue's env must go through the same override resolution `siteUrl` - // above already gets, not just the fields validation happens to check. - // `jwtIssuer` is a plain, non-throwing `legacyEnvOverride` string read, but leaving it ungated - // is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ - // `redirect_uri` above — `auth.jwt_issuer` is in `LEGACY_ENV_OVERRIDABLE_KEYS`. - const jwtIssuer = remoteWins("auth.jwt_issuer") - ? config.auth.jwt_issuer - : legacyEnvOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); - // Same remote-over-env precedence as `siteUrl` above — `jwtExpiry` reaches the shadow's own - // Postgres container spec (`legacyBuildLocalDbContainerInputs`'s `authJwtExpiry`). - const jwtExpiry = remoteWins("auth.jwt_expiry") - ? config.auth.jwt_expiry - : legacyEnvOverrideUint( - "SUPABASE_AUTH_JWT_EXPIRY", - "auth.jwt_expiry", - config.auth.jwt_expiry, - projectEnvValues, - ); - // Go decodes `additional_redirect_urls` (a `[]string`) through the same - // `StringToSliceHookFunc(",")` mapstructure hook as every other Go - // string-slice field — same comma-split-override - // pattern as `auth.webauthn.rp_origins` below. Same "non-throwing read is still a precedence - // bug" reasoning as `jwtIssuer` above — `auth.additional_redirect_urls` is also in - // `LEGACY_ENV_OVERRIDABLE_KEYS`. - const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") - ? undefined - : legacyEnvOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); - const additionalRedirectUrls = - additionalRedirectUrlsOverride !== undefined - ? additionalRedirectUrlsOverride.split(",") - : config.auth.additional_redirect_urls; - // Same remote-over-env precedence as `studioEnabled`/`mailpitEnabled` above, for the exact same - // "throws before a value the caller needs is resolved" reason — every field in this group is - // now in `LEGACY_ENV_OVERRIDABLE_KEYS`. - const enableSignup = remoteWins("auth.enable_signup") - ? config.auth.enable_signup - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_SIGNUP", - config.auth.enable_signup, - "auth.enable_signup", - projectEnvValues, - ); - const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") - ? config.auth.enable_anonymous_sign_ins - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - config.auth.enable_anonymous_sign_ins, - "auth.enable_anonymous_sign_ins", - projectEnvValues, - ); - const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") - ? config.auth.enable_refresh_token_rotation - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - config.auth.enable_refresh_token_rotation, - "auth.enable_refresh_token_rotation", - projectEnvValues, - ); - const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") - ? config.auth.refresh_token_reuse_interval - : legacyEnvOverrideUint( - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "auth.refresh_token_reuse_interval", - config.auth.refresh_token_reuse_interval, - projectEnvValues, - ); - const enableManualLinking = remoteWins("auth.enable_manual_linking") - ? config.auth.enable_manual_linking - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - config.auth.enable_manual_linking, - "auth.enable_manual_linking", - projectEnvValues, - ); - const minimumPasswordLength = remoteWins("auth.minimum_password_length") - ? config.auth.minimum_password_length - : legacyEnvOverrideUint( - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "auth.minimum_password_length", - config.auth.minimum_password_length, - projectEnvValues, + } + // `db.root_key` isn't modeled in `@supabase/config`'s schema (every other + // `db.*` field is), so it's read off the raw pre-schema document — same + // presence-based pattern as `authDocument` below. The + // default-or-configured, decrypted-if-`encrypted:` value is written verbatim into + // `/etc/postgresql-custom/pgsodium_root.key` on every start, + // going through the same secret-decrypt step every other secret field gets. + const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; + // The secret decrypt step only intercepts a STRING source value; + // any other raw TOML kind (integer, bool, array, ...) + // falls through untouched, and decoding that scalar into a secret-shaped struct is + // then rejected with exactly this message — same "decoding failed due to the following + // error(s):" wrapper already used for `auth.captcha.provider`/`analytics.backend` above. + if (rawRootKeyValue !== undefined && typeof rawRootKeyValue !== "string") { + throw new LegacyConfigValidateError( + "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", ); - const passwordRequirements = remoteWins("auth.password_requirements") - ? config.auth.password_requirements - : legacyEnvOverrideAuthPasswordRequirements( - config.auth.password_requirements, + } + // Same remote-over-env precedence as `apiPort`/`dbPort` above — `rootKey` reaches the + // shadow's own Postgres container spec (`legacyBuildLocalDbContainerInputs`). `rawRootKeyValue` + // already reflects a matched remote's `db.root_key` (`document` is the remote-merged raw doc — + // see `LoadedProjectConfig.document`'s own doc comment), so `remoteWins` here just means + // "don't let a conflicting `SUPABASE_DB_ROOT_KEY` clobber that already-merged value." + const rawRootKey = remoteWins("db.root_key") + ? rawRootKeyValue + : legacyEnvOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); + const rootKey = + rawRootKey === undefined || rawRootKey.length === 0 + ? LEGACY_POSTGRES_DEFAULT_ROOT_KEY + : (legacyDecryptAuthSecret(rawRootKey, projectEnvValues) ?? + LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + // `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` + // key right after `db.major_version`, unconditionally. + const storageBucketNames = + config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; + // `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` + // ONLY when `studio.enabled` — same + // enabled-gated pattern as `api.port` above. + // Same remote-over-env precedence as `apiEnabled`/`apiPort` above — `studio.enabled`/ + // `studio.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`): + // an ungated `legacyEnvOverrideBool`/`legacyEnvOverridePort` call here THROWS on a malformed + // `SUPABASE_STUDIO_ENABLED`/`SUPABASE_STUDIO_PORT` even when a matched remote block already + // set that field at viper's OVERRIDE tier, which would abort this whole function — and the + // shadow it feeds — on an env value the override tier should silently ignore (review: PRRT_kwDOErm0O86W6R-G). + const studioEnabled = remoteWins("studio.enabled") + ? config.studio.enabled + : legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const studioPort = remoteWins("studio.port") + ? config.studio.port + : legacyEnvOverridePort( + "SUPABASE_STUDIO_PORT", + config.studio.port, + "studio.port", + projectEnvValues, + ); + // `Config.Validate` parses `studio.api_url` with `net/url.Parse` right + // after the port check, still inside `if c.Studio.Enabled`. + // `config.studio.api_url` is a required + // (defaulted) field, so `legacyEnvOverride` can only return `undefined` here if + // that default itself were somehow undefined — the `??` fallback just + // satisfies that generic signature. + // `legacyEnvOverride` itself never throws, but `studio.api_url` feeds + // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check below, which DOES throw on a + // malformed URL — same "non-throwing read, throwing downstream consumer" bug class already + // fixed for `legacyResolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XGTq5). An + // ungated read here can flip that validate() outcome even though nothing in this read itself + // throws, so `studio.api_url` is gated the same way as `studio.enabled`/`studio.port` above. + const studioApiUrl = remoteWins("studio.api_url") + ? config.studio.api_url + : (legacyEnvOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? + config.studio.api_url); + // `Config.Validate` rejects `local_smtp.port === 0`/ + // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct + // field is still named `Inbucket` for the `[local_smtp]` TOML section, + // so `local_smtp.enabled` and the + // deprecated `inbucket.enabled` alias are the same underlying flag, not two + // independent ones. + // Same remote-over-env precedence as `studioEnabled`/`studioPort` above — `local_smtp.enabled`/ + // `local_smtp.port` are now in `LEGACY_ENV_OVERRIDABLE_KEYS` for the identical reason. + const mailpitEnabled = remoteWins("local_smtp.enabled") + ? config.local_smtp.enabled + : legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const mailpitPort = remoteWins("local_smtp.port") + ? config.local_smtp.port + : legacyEnvOverridePort( + "SUPABASE_LOCAL_SMTP_PORT", + config.local_smtp.port, + "local_smtp.port", + projectEnvValues, + ); + // Same remote-over-env precedence as `apiPort`/`dbPort`/`rootKey` above — `jwtSecret` reaches + // the shadow's own Postgres/fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`). + const jwtSecret = resolveJwtSecret( + legacyDecryptAuthSecret( + remoteWins("auth.jwt_secret") + ? config.auth.jwt_secret + : legacyEnvOverride("SUPABASE_AUTH_JWT_SECRET", config.auth.jwt_secret, projectEnvValues), projectEnvValues, - ); - // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — - // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because - // the captcha presence check right below needs it too. `undefined` for callers that haven't - // threaded `document` through yet, in which case presence-based checks are simply skipped. - const authDocument = asRecord(document?.["auth"]); - const captchaInput = legacyResolveAuthCaptcha( - authDocument, - config.auth.captcha, - projectEnvValues, - remoteOverrideKeys, - ); - // Go's `generateJWT` signs asymmetrically whenever - // `len(a.SigningKeysPath) > 0 && len(a.SigningKeys) > 0` — NOT gated on `auth.enabled`. Since - // `a.SigningKeys` is unconditionally seeded with the default ES256 key at `NewConfig()` time - // and only ever replaced by the file's keys (when the read above actually runs), it's never - // empty either way — so this reduces to "does `signing_keys_path` resolve to a key at all," - // matching {@link legacyResolveLocalJwks}'s identical `signingKeysPath`-only condition. Reuses - // {@link legacyResolveConfiguredSigningKeys} (which already gates the actual file read on - // `authEnabled` internally, matching the file-read gating above) rather than duplicating that - // gate here — a disabled-auth config with a configured path must still sign asymmetrically - // with the default key, not silently fall back to symmetric HS256. - const signingKey = - signingKeysPath !== undefined && signingKeysPath.length > 0 - ? (legacyResolveConfiguredSigningKeys( - config, - workdir, + ), + ); + // Same remote-over-env precedence as `jwtSecret` above — `signingKeysPath` gates whether + // {@link legacyResolveConfiguredSigningKeys} below produces an asymmetric `signingKey`, which + // feeds `anonKey`/`serviceRoleKey` (already remote-gated fields the shadow's setup consumes). + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, projectEnvValues, - remoteOverrideKeys, - ) ?? [LEGACY_DEFAULT_SIGNING_KEY])[0] - : undefined; - // Validation runs passkey/webauthn, hook, mfa, email, then sms/third-party checks (skipping - // the D-only `external` step, ported separately below), all right after the signing-keys read - // and only while auth is enabled. Sms - // is enforced at decode time by `@supabase/config`'s `sms` - // schema (`packages/config/src/auth/sms.ts`'s provider-switch check) for the TOML-only case, - // AND re-checked here post-env-override by {@link validateAuthSmsProviders} (called alongside - // {@link validateAuthExternalProviders}, after the single `legacyValidateResolvedConfig` call - // below) — see that function's doc comment for why both are needed. External - // is D-only per `legacy-config-validate.ts`'s module - // header; {@link validateAuthExternalProviders} ports D's identical inline check. This block - // only ACCUMULATES the inputs those checks need — the checks themselves run once, later, as - // part of the single `legacyValidateResolvedConfig` call below. - let authInput: LegacyAuthInput | undefined; - if (authEnabled) { - // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see - // `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), so passkey/webauthn - // are read from the RAW, post-`env()`-interpolation TOML document (`authDocument`, hoisted - // above) instead of the decoded `ProjectConfig` — same document-based approach already used - // on the `db`/migration config-load path (`legacy-db-config.toml-read.ts`'s - // `legacyValidateAuthConfig`, section A6). `authDocument` is `undefined` when a caller hasn't - // threaded `document` through yet, in which case passkey/smtp presence-based checks are - // simply skipped rather than guessed at. - const passkeyDoc = asRecord(authDocument?.["passkey"]); - const webauthnDoc = asRecord(authDocument?.["webauthn"]); - // `auth.passkey.enabled`/`auth.webauthn.*` are env-bindable like every other nested field once - // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml, so - // `SUPABASE_AUTH_PASSKEY_ENABLED` and - // `SUPABASE_AUTH_WEBAUTHN_RP_ID`/`_RP_ORIGINS` overrides apply before passkey/webauthn - // validation runs. Gated on the raw section already - // being present (`passkeyDoc`/`webauthnDoc !== undefined`) — only keys already present in the - // merged config are env-bindable, so an absent - // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. - // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `auth.passkey.enabled` - // is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated `legacyEnvOverrideBool` call below - // THROWS on a malformed override even when a matched remote block already set it, which would - // abort this whole function (and the shadow it feeds) on an env value the override tier should silently ignore. - const passkeyEnabled = remoteWins("auth.passkey.enabled") - ? legacyRawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") - : passkeyDoc !== undefined - ? legacyEnvOverrideBool( - "SUPABASE_AUTH_PASSKEY_ENABLED", - legacyRawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), - "auth.passkey.enabled", - projectEnvValues, - ) - : false; - // `rp_id`/`rp_origins` are plain, non-throwing `legacyEnvOverride` reads, but leaving them - // ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ - // `redirect_uri` above — `auth.webauthn.rp_id`/`.rp_origins` are in + ); + // Gated on `auth.enabled`: + // the signing-keys file read only runs when auth is enabled, so a + // disabled auth section never opens/parses `signing_keys_path`, even a stale + // or missing one. JWT-secret validation and anon/service_role key generation + // run unconditionally either way, so + // only this file read is gated. `auth.enabled` is itself env-bindable like + // any other field, so this gate reads the + // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence + // `legacyEnvOverrideBool` here instead of `config.auth.enabled` directly. + // Same remote-over-env precedence as every other gated field above — `auth.enabled` IS in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and that reader's own + // resolver already gates it (`remoteOverrideKeys.has("auth.enabled")`); this resolver must + // match, since an ungated `legacyEnvOverrideBool` call THROWS on a malformed + // `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` at + // override tier — a value validation never even evaluates the env var for in + // that case — which would otherwise abort this whole function (and the shadow it feeds via + // `legacyBuildLocalDbContainerInputs`) on an env value the override tier should silently ignore + // (review: PRRT_kwDOErm0O86W30n6). + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + // `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled`, + // before the signing-keys read below — + // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT + // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as + // `""` with no schema-level error, same gap as `db.port === 0` above. + // Same remote-over-env precedence as `jwtSecret` above — `siteUrl` reaches the shadow's own + // fresh-DB-setup spec (`legacyBuildLocalDbContainerInputs`'s `authSiteUrl`). + const siteUrl = remoteWins("auth.site_url") + ? config.auth.site_url + : (legacyEnvOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? + config.auth.site_url); + // GoTrue's env is built straight off the resolved auth config, with no local + // override logic of its own — the override happens earlier, generically, so + // every flat `auth.*` scalar fed into + // GoTrue's env must go through the same override resolution `siteUrl` + // above already gets, not just the fields validation happens to check. + // `jwtIssuer` is a plain, non-throwing `legacyEnvOverride` string read, but leaving it ungated + // is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + // `redirect_uri` above — `auth.jwt_issuer` is in `LEGACY_ENV_OVERRIDABLE_KEYS`. + const jwtIssuer = remoteWins("auth.jwt_issuer") + ? config.auth.jwt_issuer + : legacyEnvOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); + // Same remote-over-env precedence as `siteUrl` above — `jwtExpiry` reaches the shadow's own + // Postgres container spec (`legacyBuildLocalDbContainerInputs`'s `authJwtExpiry`). + const jwtExpiry = remoteWins("auth.jwt_expiry") + ? config.auth.jwt_expiry + : legacyEnvOverrideUint( + "SUPABASE_AUTH_JWT_EXPIRY", + "auth.jwt_expiry", + config.auth.jwt_expiry, + projectEnvValues, + ); + // Go decodes `additional_redirect_urls` (a `[]string`) through the same + // `StringToSliceHookFunc(",")` mapstructure hook as every other Go + // string-slice field — same comma-split-override + // pattern as `auth.webauthn.rp_origins` below. Same "non-throwing read is still a precedence + // bug" reasoning as `jwtIssuer` above — `auth.additional_redirect_urls` is also in // `LEGACY_ENV_OVERRIDABLE_KEYS`. - const configuredRpId = - typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined; - const rpId = remoteWins("auth.webauthn.rp_id") - ? configuredRpId - : webauthnDoc !== undefined - ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) - : undefined; - // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` - // mapstructure hook as every other Go string-slice field, so a - // `SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS` override is comma-split the same way. - const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") + const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") ? undefined - : webauthnDoc !== undefined - ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) - : undefined; - // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` unconditionally to - // every `[]string`-typed field — a raw or `env(...)`-resolved - // `rp_origins` string (this section has no `@supabase/config` schema at all) must be - // comma-split, not silently dropped when it isn't already a JS array. - const rawRpOrigins = webauthnDoc?.["rp_origins"]; - const rpOrigins = - rpOriginsOverride !== undefined - ? legacyStrToArr(rpOriginsOverride) - : Array.isArray(rawRpOrigins) - ? rawRpOrigins - : typeof rawRpOrigins === "string" - ? legacyStrToArr(rawRpOrigins) - : undefined; - const passkey: LegacyPasskeyInput | undefined = passkeyEnabled - ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } - : undefined; - - // Only enabled hooks are forwarded to `Config.Validate` parity, in Go's - // fixed iteration order — derived from - // `legacyResolveAuthHooks`'s unfiltered result so this validation path and - // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same - // per-hook override values (see that function's doc comment). - const resolvedHooks = legacyResolveAuthHooks( - authDocument, - config.auth.hook, - projectEnvValues, - remoteOverrideKeys, - ); - const hooks: Array<LegacyHookInput> = LEGACY_HOOK_TYPE_ORDER.filter( - (hookType) => resolvedHooks[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]].enabled, - ).map((hookType) => { - const resolved = resolvedHooks[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]]; - return { type: hookType, uri: resolved.uri, secrets: resolved.secrets }; - }); - - // Derived from `legacyResolveAuthMfa`'s unfiltered result so this validation path and - // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same per-factor override - // values (see that function's doc comment) — same precedent as `hooks` above. - const resolvedMfa = legacyResolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); - const mfa: ReadonlyArray<LegacyMfaFactorInput> = [ - { - label: "totp", - enrollEnabled: resolvedMfa.totp.enroll_enabled, - verifyEnabled: resolvedMfa.totp.verify_enabled, - }, - { - label: "phone", - enrollEnabled: resolvedMfa.phone.enroll_enabled, - verifyEnabled: resolvedMfa.phone.verify_enabled, - }, - { - label: "web_authn", - enrollEnabled: resolvedMfa.web_authn.enroll_enabled, - verifyEnabled: resolvedMfa.web_authn.verify_enabled, - }, - ]; - - // `Config.Validate` runs the email template/notification content read right after - // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` — this I/O read - // stays at this exact textual position (see this function's `@throws` doc for why). - readAuthEmailTemplateContent( - legacyResolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), - workdir, - ); - - // `[auth.email.smtp]` presence-based `enabled` default — see - // {@link legacyResolveAuthEmailSmtp}'s doc comment. - const resolvedSmtp = legacyResolveAuthEmailSmtp( + : legacyEnvOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); + const additionalRedirectUrls = + additionalRedirectUrlsOverride !== undefined + ? additionalRedirectUrlsOverride.split(",") + : config.auth.additional_redirect_urls; + // Same remote-over-env precedence as `studioEnabled`/`mailpitEnabled` above, for the exact same + // "throws before a value the caller needs is resolved" reason — every field in this group is + // now in `LEGACY_ENV_OVERRIDABLE_KEYS`. + const enableSignup = remoteWins("auth.enable_signup") + ? config.auth.enable_signup + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_SIGNUP", + config.auth.enable_signup, + "auth.enable_signup", + projectEnvValues, + ); + const enableAnonymousSignIns = remoteWins("auth.enable_anonymous_sign_ins") + ? config.auth.enable_anonymous_sign_ins + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + config.auth.enable_anonymous_sign_ins, + "auth.enable_anonymous_sign_ins", + projectEnvValues, + ); + const enableRefreshTokenRotation = remoteWins("auth.enable_refresh_token_rotation") + ? config.auth.enable_refresh_token_rotation + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + config.auth.enable_refresh_token_rotation, + "auth.enable_refresh_token_rotation", + projectEnvValues, + ); + const refreshTokenReuseInterval = remoteWins("auth.refresh_token_reuse_interval") + ? config.auth.refresh_token_reuse_interval + : legacyEnvOverrideUint( + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "auth.refresh_token_reuse_interval", + config.auth.refresh_token_reuse_interval, + projectEnvValues, + ); + const enableManualLinking = remoteWins("auth.enable_manual_linking") + ? config.auth.enable_manual_linking + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + config.auth.enable_manual_linking, + "auth.enable_manual_linking", + projectEnvValues, + ); + const minimumPasswordLength = remoteWins("auth.minimum_password_length") + ? config.auth.minimum_password_length + : legacyEnvOverrideUint( + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "auth.minimum_password_length", + config.auth.minimum_password_length, + projectEnvValues, + ); + const passwordRequirements = remoteWins("auth.password_requirements") + ? config.auth.password_requirements + : legacyEnvOverrideAuthPasswordRequirements( + config.auth.password_requirements, + projectEnvValues, + ); + // `LoadedProjectConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — + // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because + // the captcha presence check right below needs it too. `undefined` for callers that haven't + // threaded `document` through yet, in which case presence-based checks are simply skipped. + const authDocument = asRecord(document?.["auth"]); + const captchaInput = legacyResolveAuthCaptcha( authDocument, + config.auth.captcha, projectEnvValues, remoteOverrideKeys, ); - const smtp: LegacySmtpInput | undefined = - resolvedSmtp === undefined + // Go's `generateJWT` signs asymmetrically whenever + // `len(a.SigningKeysPath) > 0 && len(a.SigningKeys) > 0` — NOT gated on `auth.enabled`. Since + // `a.SigningKeys` is unconditionally seeded with the default ES256 key at `NewConfig()` time + // and only ever replaced by the file's keys (when the read above actually runs), it's never + // empty either way — so this reduces to "does `signing_keys_path` resolve to a key at all," + // matching {@link legacyResolveLocalJwks}'s identical `signingKeysPath`-only condition. Reuses + // {@link legacyResolveConfiguredSigningKeys} (which already gates the actual file read on + // `authEnabled` internally, matching the file-read gating above) rather than duplicating that + // gate here — a disabled-auth config with a configured path must still sign asymmetrically + // with the default key, not silently fall back to symmetric HS256. + const configuredSigningKeys = + signingKeysPath !== undefined && signingKeysPath.length > 0 + ? yield* legacyResolveConfiguredSigningKeys( + config, + workdir, + projectEnvValues, + remoteOverrideKeys, + ) + : undefined; + const signingKey = + configuredSigningKeys === undefined + ? signingKeysPath !== undefined && signingKeysPath.length > 0 + ? LEGACY_DEFAULT_SIGNING_KEY + : undefined + : configuredSigningKeys[0]; + // Validation runs passkey/webauthn, hook, mfa, email, then sms/third-party checks (skipping + // the D-only `external` step, ported separately below), all right after the signing-keys read + // and only while auth is enabled. Sms + // is enforced at decode time by `@supabase/config`'s `sms` + // schema (`packages/config/src/auth/sms.ts`'s provider-switch check) for the TOML-only case, + // AND re-checked here post-env-override by {@link validateAuthSmsProviders} (called alongside + // {@link validateAuthExternalProviders}, after the single `legacyValidateResolvedConfig` call + // below) — see that function's doc comment for why both are needed. External + // is D-only per `legacy-config-validate.ts`'s module + // header; {@link validateAuthExternalProviders} ports D's identical inline check. This block + // only ACCUMULATES the inputs those checks need — the checks themselves run once, later, as + // part of the single `legacyValidateResolvedConfig` call below. + let authInput: LegacyAuthInput | undefined; + if (authEnabled) { + // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see + // `config-sync/auth.sync.ts`'s "not in `@supabase/config` schema" note), so passkey/webauthn + // are read from the RAW, post-`env()`-interpolation TOML document (`authDocument`, hoisted + // above) instead of the decoded `ProjectConfig` — same document-based approach already used + // on the `db`/migration config-load path (`legacy-db-config.toml-read.ts`'s + // `legacyValidateAuthConfig`, section A6). `authDocument` is `undefined` when a caller hasn't + // threaded `document` through yet, in which case passkey/smtp presence-based checks are + // simply skipped rather than guessed at. + const passkeyDoc = asRecord(authDocument?.["passkey"]); + const webauthnDoc = asRecord(authDocument?.["webauthn"]); + // `auth.passkey.enabled`/`auth.webauthn.*` are env-bindable like every other nested field once + // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml, so + // `SUPABASE_AUTH_PASSKEY_ENABLED` and + // `SUPABASE_AUTH_WEBAUTHN_RP_ID`/`_RP_ORIGINS` overrides apply before passkey/webauthn + // validation runs. Gated on the raw section already + // being present (`passkeyDoc`/`webauthnDoc !== undefined`) — only keys already present in the + // merged config are env-bindable, so an absent + // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. + // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `auth.passkey.enabled` + // is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated `legacyEnvOverrideBool` call below + // THROWS on a malformed override even when a matched remote block already set it, which would + // abort this whole function (and the shadow it feeds) on an env value the override tier should silently ignore. + const passkeyEnabled = remoteWins("auth.passkey.enabled") + ? legacyRawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") + : passkeyDoc !== undefined + ? legacyEnvOverrideBool( + "SUPABASE_AUTH_PASSKEY_ENABLED", + legacyRawUnmodeledBool(passkeyDoc["enabled"], "auth.passkey.enabled"), + "auth.passkey.enabled", + projectEnvValues, + ) + : false; + // `rp_id`/`rp_origins` are plain, non-throwing `legacyEnvOverride` reads, but leaving them + // ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ + // `redirect_uri` above — `auth.webauthn.rp_id`/`.rp_origins` are in + // `LEGACY_ENV_OVERRIDABLE_KEYS`. + const configuredRpId = + typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined; + const rpId = remoteWins("auth.webauthn.rp_id") + ? configuredRpId + : webauthnDoc !== undefined + ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) + : undefined; + // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` + // mapstructure hook as every other Go string-slice field, so a + // `SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS` override is comma-split the same way. + const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") ? undefined - : { - enabled: resolvedSmtp.enabled, - host: resolvedSmtp.host, - port: resolvedSmtp.port, - user: resolvedSmtp.user, - pass: resolvedSmtp.pass, - adminEmail: resolvedSmtp.adminEmail, - }; - - // `(tpa *thirdParty) validate()` fixed provider order — - // only enabled providers are forwarded, in that order. {@link legacyResolveThirdPartyProviders} - // is the SAME hoisted resolver `commands/db/start/start.handler.ts`'s eager pre-probe battery - // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides — - // `remoteOverrideKeys` is threaded through so a matched remote's `auth.third_party.*` value - // doesn't lose to a malformed `SUPABASE_AUTH_THIRD_PARTY_*` override (review: - // PRRT_kwDOErm0O86W30n6), same reasoning as every other `remoteWins`-gated field above. - const thirdParty = legacyResolveThirdPartyProviders( - config.auth.third_party, - projectEnvValues, - remoteOverrideKeys, - ); - - authInput = { - siteUrl: siteUrl ?? "", - captcha: captchaInput, - passkey, - hooks, - mfa, - smtp, - thirdParty, - }; - } - // `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` - // key right after the auth block/`generateAPIKeys`, unconditionally. - const functionSlugs = Object.keys(config.functions); - // `Config.Validate` checks `edge_runtime.deno_version` after the auth - // block and the functions loop, and — - // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no - // `edge_runtime.enabled` gate. `edge_runtime.deno_version` is in - // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and - // `legacyEnvOverrideDenoVersion` THROWS on a malformed override — same - // `auth.enabled`/`analytics.enabled` bug class (review: PRRT_kwDOErm0O86W30n6, - // PRRT_kwDOErm0O86W4gCk): an ungated call here would abort this whole - // resolver (and the shadow it feeds) on a malformed `SUPABASE_EDGE_RUNTIME_ - // DENO_VERSION` even when a matched remote block already set - // `edge_runtime.deno_version` at viper's OVERRIDE tier, a value Go's - // `Validate` never evaluates the env var for in that case. - const denoVersion = remoteWins("edge_runtime.deno_version") - ? config.edge_runtime.deno_version - : legacyEnvOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); + : webauthnDoc !== undefined + ? legacyEnvOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) + : undefined; + // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` unconditionally to + // every `[]string`-typed field — a raw or `env(...)`-resolved + // `rp_origins` string (this section has no `@supabase/config` schema at all) must be + // comma-split, not silently dropped when it isn't already a JS array. + const rawRpOrigins = webauthnDoc?.["rp_origins"]; + const rpOrigins = + rpOriginsOverride !== undefined + ? legacyStrToArr(rpOriginsOverride) + : Array.isArray(rawRpOrigins) + ? rawRpOrigins + : typeof rawRpOrigins === "string" + ? legacyStrToArr(rawRpOrigins) + : undefined; + const passkey: LegacyPasskeyInput | undefined = passkeyEnabled + ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } + : undefined; - // `Config.Validate` validates `[analytics]` right after - // `edge_runtime.deno_version`: when - // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP - // fields are required, checked in that order, each with its own message. - // Backend-enum validation (rejecting a non-postgres/bigquery value) is - // covered at decode time for the `config.toml`-sourced value by - // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), - // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override - // path — see {@link envOverrideAnalyticsBackend} for that case. - // `analytics.enabled`/`analytics.backend` are both in `LEGACY_ENV_OVERRIDABLE_KEYS` - // (`legacy-db-config.toml-read.ts`) and both THROW on a malformed override - // (`LegacyInvalidBoolEnvOverrideError`/`LegacyInvalidAnalyticsBackendEnvOverrideError`) — same - // `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6): an ungated call here would abort - // this whole function (and the shadow it feeds) on a malformed `SUPABASE_ANALYTICS_*` env var - // even when a matched remote block already set the field at viper's OVERRIDE tier, a value - // `Validate` never evaluates the env var for in that case. `gcpProjectId`/ - // `gcpProjectNumber`/`gcpJwtPath` below can't throw either (`legacyEnvOverride` is a plain - // string read), but leaving them ungated is still a precedence bug, same reasoning as - // `auth.external.*`'s `client_id`/`url`/`redirect_uri` — all three are in - // `LEGACY_ENV_OVERRIDABLE_KEYS` and already gated on the `legacy-db-config.toml-read.ts` side - // (`analyticsString`); this resolver's own copy just never got the matching gate. - const analyticsEnabled = remoteWins("analytics.enabled") - ? config.analytics.enabled - : legacyEnvOverrideBool( - "SUPABASE_ANALYTICS_ENABLED", - config.analytics.enabled, - "analytics.enabled", + // Only enabled hooks are forwarded to `Config.Validate` parity, in Go's + // fixed iteration order — derived from + // `legacyResolveAuthHooks`'s unfiltered result so this validation path and + // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same + // per-hook override values (see that function's doc comment). + const resolvedHooks = legacyResolveAuthHooks( + authDocument, + config.auth.hook, projectEnvValues, + remoteOverrideKeys, ); - const analyticsBackend = envOverrideAnalyticsBackend( - config.analytics.backend, - projectEnvValues, - remoteWins("analytics.backend"), - ); - const gcpProjectId = remoteWins("analytics.gcp_project_id") - ? config.analytics.gcp_project_id - : legacyEnvOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", - config.analytics.gcp_project_id, + const hooks: Array<LegacyHookInput> = LEGACY_HOOK_TYPE_ORDER.filter( + (hookType) => resolvedHooks[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]].enabled, + ).map((hookType) => { + const resolved = resolvedHooks[LEGACY_HOOK_TYPE_TO_CAMEL[hookType]]; + return { type: hookType, uri: resolved.uri, secrets: resolved.secrets }; + }); + + // Derived from `legacyResolveAuthMfa`'s unfiltered result so this validation path and + // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same per-factor override + // values (see that function's doc comment) — same precedent as `hooks` above. + const resolvedMfa = legacyResolveAuthMfa( + config.auth.mfa, projectEnvValues, + remoteOverrideKeys, ); - const gcpProjectNumber = remoteWins("analytics.gcp_project_number") - ? config.analytics.gcp_project_number - : legacyEnvOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", - config.analytics.gcp_project_number, - projectEnvValues, + const mfa: ReadonlyArray<LegacyMfaFactorInput> = [ + { + label: "totp", + enrollEnabled: resolvedMfa.totp.enroll_enabled, + verifyEnabled: resolvedMfa.totp.verify_enabled, + }, + { + label: "phone", + enrollEnabled: resolvedMfa.phone.enroll_enabled, + verifyEnabled: resolvedMfa.phone.verify_enabled, + }, + { + label: "web_authn", + enrollEnabled: resolvedMfa.web_authn.enroll_enabled, + verifyEnabled: resolvedMfa.web_authn.verify_enabled, + }, + ]; + + // `Config.Validate` runs the email template/notification content read right after + // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` — this I/O read + // stays at this exact textual position (see this function's `@throws` doc for why). + yield* readAuthEmailTemplateContent( + legacyResolveAuthEmail( + config.auth.email, + authDocument, + projectEnvValues, + remoteOverrideKeys, + ), + workdir, ); - const gcpJwtPath = remoteWins("analytics.gcp_jwt_path") - ? config.analytics.gcp_jwt_path - : legacyEnvOverride( - "SUPABASE_ANALYTICS_GCP_JWT_PATH", - config.analytics.gcp_jwt_path, + + // `[auth.email.smtp]` presence-based `enabled` default — see + // {@link legacyResolveAuthEmailSmtp}'s doc comment. + const resolvedSmtp = legacyResolveAuthEmailSmtp( + authDocument, projectEnvValues, + remoteOverrideKeys, ); + const smtp: LegacySmtpInput | undefined = + resolvedSmtp === undefined + ? undefined + : { + enabled: resolvedSmtp.enabled, + host: resolvedSmtp.host, + port: resolvedSmtp.port, + user: resolvedSmtp.user, + pass: resolvedSmtp.pass, + adminEmail: resolvedSmtp.adminEmail, + }; - // `Config.Validate` calls `c.Experimental.validate()` right after the - // analytics/bigquery block and right before returning. The webhooks check is NOT "the user - // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && - // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` - // isn't explicitly `true`, including one where the key is simply omitted; the section exists - // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML - // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default - // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills - // in `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` even when the - // TOML section is entirely absent — verified empirically, this default-fill erases exactly the - // presence signal this check needs. So this reads `LoadedProjectConfig.document` (the raw, - // pre-default TOML) instead, same as the passkey/smtp checks above. - const experimentalDocument = asRecord(document?.["experimental"]); - const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; - // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED`/`SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` are - // env-bindable like every other leaf field before experimental validation runs — same - // mechanism the db/migration - // loader (`legacy-db-config.toml-read.ts`) already applies for the `pgdelta` override; this - // resolver just never got the equivalent treatment. A malformed JSON override needs no separate - // error path here: it flows through unchanged and `legacyValidateResolvedConfig`'s existing - // `isValidJson` check reports it the same way it already reports a malformed TOML-sourced value. - // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `experimental. - // webhooks.enabled` is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated - // `legacyEnvOverrideBool` call below THROWS on a malformed override even when a matched remote - // block already set it, which would abort this whole function (and the shadow it feeds) on an - // env value the override tier should silently ignore. - const webhooksEnabled = remoteWins("experimental.webhooks.enabled") - ? config.experimental.webhooks?.enabled === true - : legacyEnvOverrideBool( - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - config.experimental.webhooks?.enabled === true, - "experimental.webhooks.enabled", + // `(tpa *thirdParty) validate()` fixed provider order — + // only enabled providers are forwarded, in that order. {@link legacyResolveThirdPartyProviders} + // is the SAME hoisted resolver `commands/db/start/start.handler.ts`'s eager pre-probe battery + // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides — + // `remoteOverrideKeys` is threaded through so a matched remote's `auth.third_party.*` value + // doesn't lose to a malformed `SUPABASE_AUTH_THIRD_PARTY_*` override (review: + // PRRT_kwDOErm0O86W30n6), same reasoning as every other `remoteWins`-gated field above. + const thirdParty = legacyResolveThirdPartyProviders( + config.auth.third_party, projectEnvValues, + remoteOverrideKeys, ); - // `experimental.pgdelta.format_options` is ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS` - // (`legacy-db-config.toml-read.ts`), which already gates its OWN `format_options` read the - // same way (`remoteOverrideKeys.has("experimental.pgdelta.format_options")`) — this resolver's - // copy just never got the matching gate: an ungated `legacyEnvOverride` here let ambient - // `SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` beat a matched remote's own `format_options`, - // the opposite of `mergeRemoteConfig`, which installs the remote leaf with `v.Set` ABOVE - // `AutomaticEnv` — same remote-over-env precedence as `webhooksEnabled` - // immediately above. - const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") - ? (config.experimental.pgdelta?.format_options ?? "") - : (legacyEnvOverride( - "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", - config.experimental.pgdelta?.format_options, - projectEnvValues, - ) ?? ""); - // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is - // deferred to this single call, positioned here (where the last of those checks ran until - // this commit), in Go's exact relative order against every OTHER pure check. This means a - // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers - // first among the ones broken — unchanged from before. The only real reordering risk is - // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls - // cert/key, email template/notification content) that in THIS function's source sits between - // two pure sections (e.g. the signing-keys read sits between the captcha check above and the - // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O - // read now effectively runs BEFORE those later pure checks rather than interleaved at its - // original relative position. This is the same narrow, accepted, documented tradeoff recorded - // in `legacy-config-validate.ts`'s module header; every existing test constructs exactly one - // validation failure at a time, so it has zero effect on any real test. - const apiInput: LegacyApiInput = { - enabled: apiEnabled, - port: apiPort, - tls: { enabled: apiTlsEnabled, certPath: apiTlsCertPath, keyPath: apiTlsKeyPath }, - }; - const dbInput: LegacyDbInput = { port: dbPort, majorVersion }; - const studioInput: LegacyStudioInput = { - enabled: studioEnabled, - port: studioPort, - apiUrl: studioApiUrl, - }; - const localSmtpInput: LegacyLocalSmtpInput = { enabled: mailpitEnabled, port: mailpitPort }; - const analyticsInput: LegacyAnalyticsInput = { - enabled: analyticsEnabled, - backend: analyticsBackend, - gcpProjectId: gcpProjectId ?? "", - gcpProjectNumber: gcpProjectNumber ?? "", - gcpJwtPath: gcpJwtPath ?? "", - }; - const experimentalInput: LegacyExperimentalInput = { - webhooksPresent, - webhooksEnabled, - pgdeltaFormatOptions, - }; + authInput = { + siteUrl: siteUrl ?? "", + captcha: captchaInput, + passkey, + hooks, + mfa, + smtp, + thirdParty, + }; + } + // `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` + // key right after the auth block/`generateAPIKeys`, unconditionally. + const functionSlugs = Object.keys(config.functions); + // `Config.Validate` checks `edge_runtime.deno_version` after the auth + // block and the functions loop, and — + // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no + // `edge_runtime.enabled` gate. `edge_runtime.deno_version` is in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and + // `legacyEnvOverrideDenoVersion` THROWS on a malformed override — same + // `auth.enabled`/`analytics.enabled` bug class (review: PRRT_kwDOErm0O86W30n6, + // PRRT_kwDOErm0O86W4gCk): an ungated call here would abort this whole + // resolver (and the shadow it feeds) on a malformed `SUPABASE_EDGE_RUNTIME_ + // DENO_VERSION` even when a matched remote block already set + // `edge_runtime.deno_version` at viper's OVERRIDE tier, a value Go's + // `Validate` never evaluates the env var for in that case. + const denoVersion = remoteWins("edge_runtime.deno_version") + ? config.edge_runtime.deno_version + : legacyEnvOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); - const input: LegacyConfigValidationInput = { - projectId: resolvedProjectId, - api: apiInput, - db: dbInput, - storageBucketNames, - studio: studioInput, - localSmtp: localSmtpInput, - auth: authInput, - functionSlugs, - edgeRuntimeDenoVersion: denoVersion, - analytics: analyticsInput, - experimental: experimentalInput, - }; - legacyValidateResolvedConfig(input); - // Both run after the single shared `legacyValidateResolvedConfig` call per the module's - // documented sms/external-vs-third_party ordering tradeoff (third_party is checked inside that - // call; sms/external run after it here) — in Go's own relative sms-then-external order. - // `validateAuthSmsProviders` re-runs `@supabase/config`'s schema-level - // switch with env overrides applied (see its doc comment); `validateAuthExternalProviders` is - // D-only per `legacy-config-validate.ts`'s module header ("auth.external ... stays 100% inline - // in D") — this is L's port of D's identical inline block. - if (authEnabled) { - validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); - validateAuthExternalProviders( - authDocument, - config.auth.external, + // `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version`: when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order, each with its own message. + // Backend-enum validation (rejecting a non-postgres/bigquery value) is + // covered at decode time for the `config.toml`-sourced value by + // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), + // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override + // path — see {@link envOverrideAnalyticsBackend} for that case. + // `analytics.enabled`/`analytics.backend` are both in `LEGACY_ENV_OVERRIDABLE_KEYS` + // (`legacy-db-config.toml-read.ts`) and both THROW on a malformed override + // (`LegacyInvalidBoolEnvOverrideError`/`LegacyInvalidAnalyticsBackendEnvOverrideError`) — same + // `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6): an ungated call here would abort + // this whole function (and the shadow it feeds) on a malformed `SUPABASE_ANALYTICS_*` env var + // even when a matched remote block already set the field at viper's OVERRIDE tier, a value + // `Validate` never evaluates the env var for in that case. `gcpProjectId`/ + // `gcpProjectNumber`/`gcpJwtPath` below can't throw either (`legacyEnvOverride` is a plain + // string read), but leaving them ungated is still a precedence bug, same reasoning as + // `auth.external.*`'s `client_id`/`url`/`redirect_uri` — all three are in + // `LEGACY_ENV_OVERRIDABLE_KEYS` and already gated on the `legacy-db-config.toml-read.ts` side + // (`analyticsString`); this resolver's own copy just never got the matching gate. + const analyticsEnabled = remoteWins("analytics.enabled") + ? config.analytics.enabled + : legacyEnvOverrideBool( + "SUPABASE_ANALYTICS_ENABLED", + config.analytics.enabled, + "analytics.enabled", + projectEnvValues, + ); + const analyticsBackend = envOverrideAnalyticsBackend( + config.analytics.backend, projectEnvValues, - remoteOverrideKeys, + remoteWins("analytics.backend"), ); - } + const gcpProjectId = remoteWins("analytics.gcp_project_id") + ? config.analytics.gcp_project_id + : legacyEnvOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + config.analytics.gcp_project_id, + projectEnvValues, + ); + const gcpProjectNumber = remoteWins("analytics.gcp_project_number") + ? config.analytics.gcp_project_number + : legacyEnvOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + config.analytics.gcp_project_number, + projectEnvValues, + ); + const gcpJwtPath = remoteWins("analytics.gcp_jwt_path") + ? config.analytics.gcp_jwt_path + : legacyEnvOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + config.analytics.gcp_jwt_path, + projectEnvValues, + ); - // `studio.openai_api_key` is a `config.Secret`, decrypted the same - // way `auth.email.smtp.pass`/`auth.captcha.secret` are — same remote-over-env precedence: an - // ungated `legacyEnvOverride` here could let a malformed ambient `SUPABASE_STUDIO_OPENAI_API_KEY` - // outrank a matched remote's own valid value and throw during decryption, aborting the whole - // call (and the shadow it feeds) on a value `v.Set` (override tier) silently ignores. - const openaiApiKey = remoteWins("studio.openai_api_key") - ? legacyDecryptAuthSecret(config.studio.openai_api_key, projectEnvValues) - : legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_STUDIO_OPENAI_API_KEY", - config.studio.openai_api_key, + // `Config.Validate` calls `c.Experimental.validate()` right after the + // analytics/bigquery block and right before returning. The webhooks check is NOT "the user + // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && + // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` + // isn't explicitly `true`, including one where the key is simply omitted; the section exists + // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML + // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default + // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills + // in `experimental.webhooks = { enabled: false }` on the DECODED `ProjectConfig` even when the + // TOML section is entirely absent — verified empirically, this default-fill erases exactly the + // presence signal this check needs. So this reads `LoadedProjectConfig.document` (the raw, + // pre-default TOML) instead, same as the passkey/smtp checks above. + const experimentalDocument = asRecord(document?.["experimental"]); + const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; + // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED`/`SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` are + // env-bindable like every other leaf field before experimental validation runs — same + // mechanism the db/migration + // loader (`legacy-db-config.toml-read.ts`) already applies for the `pgdelta` override; this + // resolver just never got the equivalent treatment. A malformed JSON override needs no separate + // error path here: it flows through unchanged and `legacyValidateResolvedConfig`'s existing + // `isValidJson` check reports it the same way it already reports a malformed TOML-sourced value. + // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `experimental. + // webhooks.enabled` is in `LEGACY_ENV_OVERRIDABLE_KEYS` because the ungated + // `legacyEnvOverrideBool` call below THROWS on a malformed override even when a matched remote + // block already set it, which would abort this whole function (and the shadow it feeds) on an + // env value the override tier should silently ignore. + const webhooksEnabled = remoteWins("experimental.webhooks.enabled") + ? config.experimental.webhooks?.enabled === true + : legacyEnvOverrideBool( + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", + config.experimental.webhooks?.enabled === true, + "experimental.webhooks.enabled", projectEnvValues, - ), + ); + // `experimental.pgdelta.format_options` is ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS` + // (`legacy-db-config.toml-read.ts`), which already gates its OWN `format_options` read the + // same way (`remoteOverrideKeys.has("experimental.pgdelta.format_options")`) — this resolver's + // copy just never got the matching gate: an ungated `legacyEnvOverride` here let ambient + // `SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` beat a matched remote's own `format_options`, + // the opposite of `mergeRemoteConfig`, which installs the remote leaf with `v.Set` ABOVE + // `AutomaticEnv` — same remote-over-env precedence as `webhooksEnabled` + // immediately above. + const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") + ? (config.experimental.pgdelta?.format_options ?? "") + : (legacyEnvOverride( + "SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", + config.experimental.pgdelta?.format_options, + projectEnvValues, + ) ?? ""); + + // Every PURE Config.Validate check this module/legacy-config-validate.ts jointly own is + // deferred to this single call, positioned here (where the last of those checks ran until + // this commit), in Go's exact relative order against every OTHER pure check. This means a + // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers + // first among the ones broken — unchanged from before. The only real reordering risk is + // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls + // cert/key, email template/notification content) that in THIS function's source sits between + // two pure sections (e.g. the signing-keys read sits between the captcha check above and the + // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O + // read now effectively runs BEFORE those later pure checks rather than interleaved at its + // original relative position. This is the same narrow, accepted, documented tradeoff recorded + // in `legacy-config-validate.ts`'s module header; every existing test constructs exactly one + // validation failure at a time, so it has zero effect on any real test. + const apiInput: LegacyApiInput = { + enabled: apiEnabled, + port: apiPort, + tls: { enabled: apiTlsEnabled, certPath: apiTlsCertPath, keyPath: apiTlsKeyPath }, + }; + const dbInput: LegacyDbInput = { port: dbPort, majorVersion }; + const studioInput: LegacyStudioInput = { + enabled: studioEnabled, + port: studioPort, + apiUrl: studioApiUrl, + }; + const localSmtpInput: LegacyLocalSmtpInput = { enabled: mailpitEnabled, port: mailpitPort }; + const analyticsInput: LegacyAnalyticsInput = { + enabled: analyticsEnabled, + backend: analyticsBackend, + gcpProjectId: gcpProjectId ?? "", + gcpProjectNumber: gcpProjectNumber ?? "", + gcpJwtPath: gcpJwtPath ?? "", + }; + const experimentalInput: LegacyExperimentalInput = { + webhooksPresent, + webhooksEnabled, + pgdeltaFormatOptions, + }; + + const input: LegacyConfigValidationInput = { + projectId: resolvedProjectId, + api: apiInput, + db: dbInput, + storageBucketNames, + studio: studioInput, + localSmtp: localSmtpInput, + auth: authInput, + functionSlugs, + edgeRuntimeDenoVersion: denoVersion, + analytics: analyticsInput, + experimental: experimentalInput, + }; + legacyValidateResolvedConfig(input); + // Both run after the single shared `legacyValidateResolvedConfig` call per the module's + // documented sms/external-vs-third_party ordering tradeoff (third_party is checked inside that + // call; sms/external run after it here) — in Go's own relative sms-then-external order. + // `validateAuthSmsProviders` re-runs `@supabase/config`'s schema-level + // switch with env overrides applied (see its doc comment); `validateAuthExternalProviders` is + // D-only per `legacy-config-validate.ts`'s module header ("auth.external ... stays 100% inline + // in D") — this is L's port of D's identical inline block. + if (authEnabled) { + validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); + validateAuthExternalProviders( + authDocument, + config.auth.external, projectEnvValues, + remoteOverrideKeys, ); + } - return { - apiUrl: apiExternalUrl, - apiPort, - dbPort, - studioPort, - rootKey, - openaiApiKey, - authSiteUrl: siteUrl, - authJwtIssuer: jwtIssuer, - authJwtExpiry: jwtExpiry, - authAdditionalRedirectUrls: additionalRedirectUrls, - authEnableSignup: enableSignup, - authEnableAnonymousSignIns: enableAnonymousSignIns, - authEnableRefreshTokenRotation: enableRefreshTokenRotation, - authRefreshTokenReuseInterval: refreshTokenReuseInterval, - authEnableManualLinking: enableManualLinking, - authMinimumPasswordLength: minimumPasswordLength, - authPasswordRequirements: passwordRequirements, - restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), - graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), - functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), - mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), - studioUrl: `http://${hostname}:${studioPort}`, - mailpitUrl: `http://${hostname}:${mailpitPort}`, - dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, - // `auth.publishable_key`/`auth.secret_key` are - // `config.Secret`-typed exactly like `anon_key`/`service_role_key` below — same - // remote-over-env precedence: an ungated `legacyEnvOverride` here could let a malformed - // ambient `SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY` outrank a matched - // remote's own valid value and throw during decryption, aborting the whole call. - publishableKey: resolveOpaqueKey( - remoteWins("auth.publishable_key") - ? legacyDecryptAuthSecret(config.auth.publishable_key, projectEnvValues) - : legacyDecryptAuthSecret( - legacyEnvOverride( - "SUPABASE_AUTH_PUBLISHABLE_KEY", - config.auth.publishable_key, - projectEnvValues, - ), - projectEnvValues, - ), - defaultPublishableKey, - ), - secretKey: resolveOpaqueKey( - remoteWins("auth.secret_key") - ? legacyDecryptAuthSecret(config.auth.secret_key, projectEnvValues) - : legacyDecryptAuthSecret( - legacyEnvOverride("SUPABASE_AUTH_SECRET_KEY", config.auth.secret_key, projectEnvValues), + // `studio.openai_api_key` is a `config.Secret`, decrypted the same + // way `auth.email.smtp.pass`/`auth.captcha.secret` are — same remote-over-env precedence: an + // ungated `legacyEnvOverride` here could let a malformed ambient `SUPABASE_STUDIO_OPENAI_API_KEY` + // outrank a matched remote's own valid value and throw during decryption, aborting the whole + // call (and the shadow it feeds) on a value `v.Set` (override tier) silently ignores. + const openaiApiKey = remoteWins("studio.openai_api_key") + ? legacyDecryptAuthSecret(config.studio.openai_api_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_STUDIO_OPENAI_API_KEY", + config.studio.openai_api_key, projectEnvValues, ), - defaultSecretKey, - ), - jwtSecret, - // Same remote-over-env precedence as `jwtSecret`/`siteUrl` above — `anonKey`/ - // `serviceRoleKey` reach the shadow's own fresh-DB-setup spec - // (`legacyBuildLocalDbContainerInputs`). - anonKey: resolveSignedKey( - legacyDecryptAuthSecret( - remoteWins("auth.anon_key") - ? config.auth.anon_key - : legacyEnvOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), - projectEnvValues, + projectEnvValues, + ); + + return { + apiUrl: apiExternalUrl, + apiPort, + dbPort, + studioPort, + rootKey, + openaiApiKey, + authSiteUrl: siteUrl, + authJwtIssuer: jwtIssuer, + authJwtExpiry: jwtExpiry, + authAdditionalRedirectUrls: additionalRedirectUrls, + authEnableSignup: enableSignup, + authEnableAnonymousSignIns: enableAnonymousSignIns, + authEnableRefreshTokenRotation: enableRefreshTokenRotation, + authRefreshTokenReuseInterval: refreshTokenReuseInterval, + authEnableManualLinking: enableManualLinking, + authMinimumPasswordLength: minimumPasswordLength, + authPasswordRequirements: passwordRequirements, + restUrl: apiUrlWithPath(apiExternalUrl, "/rest/v1"), + graphqlUrl: apiUrlWithPath(apiExternalUrl, "/graphql/v1"), + functionsUrl: apiUrlWithPath(apiExternalUrl, "/functions/v1"), + mcpUrl: apiUrlWithPath(apiExternalUrl, "/mcp"), + studioUrl: `http://${hostname}:${studioPort}`, + mailpitUrl: `http://${hostname}:${mailpitPort}`, + dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, + // `auth.publishable_key`/`auth.secret_key` are + // `config.Secret`-typed exactly like `anon_key`/`service_role_key` below — same + // remote-over-env precedence: an ungated `legacyEnvOverride` here could let a malformed + // ambient `SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY` outrank a matched + // remote's own valid value and throw during decryption, aborting the whole call. + publishableKey: resolveOpaqueKey( + remoteWins("auth.publishable_key") + ? legacyDecryptAuthSecret(config.auth.publishable_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_AUTH_PUBLISHABLE_KEY", + config.auth.publishable_key, + projectEnvValues, + ), + projectEnvValues, + ), + defaultPublishableKey, ), - jwtSecret, - signingKey, - "anon", - ), - serviceRoleKey: resolveSignedKey( - legacyDecryptAuthSecret( - remoteWins("auth.service_role_key") - ? config.auth.service_role_key - : legacyEnvOverride( - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - config.auth.service_role_key, + secretKey: resolveOpaqueKey( + remoteWins("auth.secret_key") + ? legacyDecryptAuthSecret(config.auth.secret_key, projectEnvValues) + : legacyDecryptAuthSecret( + legacyEnvOverride( + "SUPABASE_AUTH_SECRET_KEY", + config.auth.secret_key, + projectEnvValues, + ), projectEnvValues, ), - projectEnvValues, + defaultSecretKey, ), jwtSecret, - signingKey, - "service_role", + // Same remote-over-env precedence as `jwtSecret`/`siteUrl` above — `anonKey`/ + // `serviceRoleKey` reach the shadow's own fresh-DB-setup spec + // (`legacyBuildLocalDbContainerInputs`). + anonKey: resolveSignedKey( + legacyDecryptAuthSecret( + remoteWins("auth.anon_key") + ? config.auth.anon_key + : legacyEnvOverride("SUPABASE_AUTH_ANON_KEY", config.auth.anon_key, projectEnvValues), + projectEnvValues, + ), + jwtSecret, + signingKey, + "anon", + nowSeconds, + ), + serviceRoleKey: resolveSignedKey( + legacyDecryptAuthSecret( + remoteWins("auth.service_role_key") + ? config.auth.service_role_key + : legacyEnvOverride( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + config.auth.service_role_key, + projectEnvValues, + ), + projectEnvValues, + ), + jwtSecret, + signingKey, + "service_role", + nowSeconds, + ), + storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), + storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, + storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, + storageS3Region: DEFAULT_S3_REGION, + analyticsEnabled, + analyticsBackend, + gcpProjectId: gcpProjectId ?? "", + gcpProjectNumber: gcpProjectNumber ?? "", + gcpJwtPath: gcpJwtPath ?? "", + // Sanitized here (not above, in `input.projectId`) — `legacyValidateResolvedConfig`'s check is + // presence-only and must see the raw value to reject an explicit `project_id = ""` before any + // fallback; every OTHER reader of the project id (Docker resource naming, labels) needs the + // post-validation sanitized singleton. + projectId: legacySanitizeProjectId(resolvedProjectId ?? ""), + edgeRuntimeDenoVersion: denoVersion, + }; + }).pipe( + // Config parity validation predates this Effect boundary and still throws + // its tagged domain errors synchronously. Recover only those known + // validation defects into the declared error channel; preserve every + // unrelated defect unchanged. + Effect.catchDefect((defect) => + defect instanceof LegacyConfigValidateError || defect instanceof LegacyInvalidJwtSecretError + ? Effect.fail(defect) + : defect instanceof LegacyInvalidPortEnvOverrideError || + defect instanceof LegacyInvalidBoolEnvOverrideError || + defect instanceof LegacyInvalidAnalyticsBackendEnvOverrideError || + defect instanceof LegacyInvalidRealtimeIpVersionEnvOverrideError || + defect instanceof LegacyInvalidPoolModeEnvOverrideError || + defect instanceof LegacyInvalidEdgeRuntimePolicyEnvOverrideError || + defect instanceof LegacyInvalidSessionReplicationRoleEnvOverrideError || + defect instanceof LegacyInvalidUintEnvOverrideError || + defect instanceof LegacyInvalidAuthPasswordRequirementsEnvOverrideError + ? Effect.fail(new LegacyConfigValidateError(defect.message)) + : Effect.die(defect), ), - storageS3Url: apiUrlWithPath(apiExternalUrl, "/storage/v1/s3"), - storageS3AccessKeyId: DEFAULT_S3_ACCESS_KEY_ID, - storageS3SecretAccessKey: DEFAULT_S3_SECRET_ACCESS_KEY, - storageS3Region: DEFAULT_S3_REGION, - analyticsEnabled, - analyticsBackend, - gcpProjectId: gcpProjectId ?? "", - gcpProjectNumber: gcpProjectNumber ?? "", - gcpJwtPath: gcpJwtPath ?? "", - // Sanitized here (not above, in `input.projectId`) — `legacyValidateResolvedConfig`'s check is - // presence-only and must see the raw value to reject an explicit `project_id = ""` before any - // fallback; every OTHER reader of the project id (Docker resource naming, labels) needs the - // post-validation sanitized singleton. - projectId: legacySanitizeProjectId(resolvedProjectId ?? ""), - edgeRuntimeDenoVersion: denoVersion, - }; + ); } /** @@ -4077,199 +4249,202 @@ export function legacyResolveLocalConfigValues( * the `db diff --linked`/`db pull` path (CLI-1956), via `legacyBuildLocalDbContainerInputs` * (review: PRRT_kwDOErm0O86W3Ox_). */ -export async function legacyResolveLocalJwks( +export function legacyResolveLocalJwks( config: ProjectConfig, workdir: string, jwtSecret: string, - projectEnvValues: Readonly<Record<string, string>> | undefined = undefined, + projectEnvValues?: Readonly<Record<string, string>>, remoteOverrideKeys: ReadonlySet<string> = new Set(), -): Promise<string> { - const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); - const signingKeysPath = remoteWins("auth.signing_keys_path") - ? config.auth.signing_keys_path - : legacyEnvOverride( - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - config.auth.signing_keys_path, - projectEnvValues, - ); - // The signing keys are UNCONDITIONALLY seeded with the single default ES256 key — - // every resolved config carries it, - // regardless of `auth.enabled`. It is only ever REPLACED by a configured - // `signing_keys_path` file, and only when that file is actually read (gated on - // `auth.enabled && signing_keys_path set` — see - // {@link legacyResolveConfiguredSigningKeys}). So JWKS resolution (which has no - // `auth.enabled` gate of its own) always publishes either the - // file's keys or this default — never neither. `GOTRUE_JWT_KEYS` signs with the same - // default (`services/gotrue.service.ts`'s `LEGACY_GOTRUE_DEFAULT_SIGNING_KEY`), so the - // two must never disagree on which key applies here. - const signingKeys: ReadonlyArray<LegacyJwk> = legacyResolveConfiguredSigningKeys( - config, - workdir, - projectEnvValues, - remoteOverrideKeys, - ) ?? [LEGACY_DEFAULT_SIGNING_KEY]; +): Effect.Effect< + string, + LegacyConfigValidateError, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path +> { + return Effect.gen(function* () { + const remoteWins = legacyMakeRemoteWins(remoteOverrideKeys); + const signingKeysPath = remoteWins("auth.signing_keys_path") + ? config.auth.signing_keys_path + : legacyEnvOverride( + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + config.auth.signing_keys_path, + projectEnvValues, + ); + // The signing keys are UNCONDITIONALLY seeded with the single default ES256 key — + // every resolved config carries it, + // regardless of `auth.enabled`. It is only ever REPLACED by a configured + // `signing_keys_path` file, and only when that file is actually read (gated on + // `auth.enabled && signing_keys_path set` — see + // {@link legacyResolveConfiguredSigningKeys}). So JWKS resolution (which has no + // `auth.enabled` gate of its own) always publishes either the + // file's keys or this default — never neither. `GOTRUE_JWT_KEYS` signs with the same + // default (`services/gotrue.service.ts`'s `LEGACY_GOTRUE_DEFAULT_SIGNING_KEY`), so the + // two must never disagree on which key applies here. + const signingKeys: ReadonlyArray<LegacyJwk> = (yield* legacyResolveConfiguredSigningKeys( + config, + workdir, + projectEnvValues, + remoteOverrideKeys, + )) ?? [LEGACY_DEFAULT_SIGNING_KEY]; - // Same fixed provider order + `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides as the - // `thirdParty: Array<LegacyThirdPartyInput>` block in `legacyResolveLocalConfigValues` above, - // but built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's - // `tenant_region`) rather than `LegacyThirdPartyInput` (a validation-only shape with no - // `tenant_region` field) — {@link resolveThirdPartyIssuerUrl} needs the full set to build the - // issuer URL, not just validate presence. Each field below prefers the remote-set value over a - // conflicting env override, same as {@link legacyResolveDbSettingsEnvOverrides}'s per-field gate. - const thirdParty: ThirdPartyProvidersLike = { - firebase: { - enabled: remoteWins("auth.third_party.firebase.enabled") - ? config.auth.third_party.firebase.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", - config.auth.third_party.firebase.enabled, - "auth.third_party.firebase.enabled", - projectEnvValues, - ), - project_id: remoteWins("auth.third_party.firebase.project_id") - ? config.auth.third_party.firebase.project_id - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", - config.auth.third_party.firebase.project_id, - projectEnvValues, - ), - }, - auth0: { - enabled: remoteWins("auth.third_party.auth0.enabled") - ? config.auth.third_party.auth0.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", - config.auth.third_party.auth0.enabled, - "auth.third_party.auth0.enabled", - projectEnvValues, - ), - tenant: remoteWins("auth.third_party.auth0.tenant") - ? config.auth.third_party.auth0.tenant - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", - config.auth.third_party.auth0.tenant, - projectEnvValues, - ), - tenant_region: remoteWins("auth.third_party.auth0.tenant_region") - ? config.auth.third_party.auth0.tenant_region - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", - config.auth.third_party.auth0.tenant_region, - projectEnvValues, - ), - }, - aws_cognito: { - enabled: remoteWins("auth.third_party.aws_cognito.enabled") - ? config.auth.third_party.aws_cognito.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", - config.auth.third_party.aws_cognito.enabled, - "auth.third_party.aws_cognito.enabled", - projectEnvValues, - ), - user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") - ? config.auth.third_party.aws_cognito.user_pool_id - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", - config.auth.third_party.aws_cognito.user_pool_id, - projectEnvValues, - ), - user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") - ? config.auth.third_party.aws_cognito.user_pool_region - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", - config.auth.third_party.aws_cognito.user_pool_region, - projectEnvValues, - ), - }, - clerk: { - enabled: remoteWins("auth.third_party.clerk.enabled") - ? config.auth.third_party.clerk.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", - config.auth.third_party.clerk.enabled, - "auth.third_party.clerk.enabled", - projectEnvValues, - ), - domain: remoteWins("auth.third_party.clerk.domain") - ? config.auth.third_party.clerk.domain - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", - config.auth.third_party.clerk.domain, - projectEnvValues, - ), - }, - workos: { - enabled: remoteWins("auth.third_party.workos.enabled") - ? config.auth.third_party.workos.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", - config.auth.third_party.workos.enabled, - "auth.third_party.workos.enabled", - projectEnvValues, - ), - issuer_url: remoteWins("auth.third_party.workos.issuer_url") - ? config.auth.third_party.workos.issuer_url - : legacyEnvOverride( - "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", - config.auth.third_party.workos.issuer_url, - projectEnvValues, - ), - }, - }; + // Same fixed provider order + `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides as the + // `thirdParty: Array<LegacyThirdPartyInput>` block in `legacyResolveLocalConfigValues` above, + // but built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's + // `tenant_region`) rather than `LegacyThirdPartyInput` (a validation-only shape with no + // `tenant_region` field) — {@link resolveThirdPartyIssuerUrl} needs the full set to build the + // issuer URL, not just validate presence. Each field below prefers the remote-set value over a + // conflicting env override, same as {@link legacyResolveDbSettingsEnvOverrides}'s per-field gate. + const thirdParty: ThirdPartyProvidersLike = { + firebase: { + enabled: remoteWins("auth.third_party.firebase.enabled") + ? config.auth.third_party.firebase.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", + config.auth.third_party.firebase.enabled, + "auth.third_party.firebase.enabled", + projectEnvValues, + ), + project_id: remoteWins("auth.third_party.firebase.project_id") + ? config.auth.third_party.firebase.project_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", + config.auth.third_party.firebase.project_id, + projectEnvValues, + ), + }, + auth0: { + enabled: remoteWins("auth.third_party.auth0.enabled") + ? config.auth.third_party.auth0.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_ENABLED", + config.auth.third_party.auth0.enabled, + "auth.third_party.auth0.enabled", + projectEnvValues, + ), + tenant: remoteWins("auth.third_party.auth0.tenant") + ? config.auth.third_party.auth0.tenant + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT", + config.auth.third_party.auth0.tenant, + projectEnvValues, + ), + tenant_region: remoteWins("auth.third_party.auth0.tenant_region") + ? config.auth.third_party.auth0.tenant_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AUTH0_TENANT_REGION", + config.auth.third_party.auth0.tenant_region, + projectEnvValues, + ), + }, + aws_cognito: { + enabled: remoteWins("auth.third_party.aws_cognito.enabled") + ? config.auth.third_party.aws_cognito.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_ENABLED", + config.auth.third_party.aws_cognito.enabled, + "auth.third_party.aws_cognito.enabled", + projectEnvValues, + ), + user_pool_id: remoteWins("auth.third_party.aws_cognito.user_pool_id") + ? config.auth.third_party.aws_cognito.user_pool_id + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_ID", + config.auth.third_party.aws_cognito.user_pool_id, + projectEnvValues, + ), + user_pool_region: remoteWins("auth.third_party.aws_cognito.user_pool_region") + ? config.auth.third_party.aws_cognito.user_pool_region + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_AWS_COGNITO_USER_POOL_REGION", + config.auth.third_party.aws_cognito.user_pool_region, + projectEnvValues, + ), + }, + clerk: { + enabled: remoteWins("auth.third_party.clerk.enabled") + ? config.auth.third_party.clerk.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", + config.auth.third_party.clerk.enabled, + "auth.third_party.clerk.enabled", + projectEnvValues, + ), + domain: remoteWins("auth.third_party.clerk.domain") + ? config.auth.third_party.clerk.domain + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", + config.auth.third_party.clerk.domain, + projectEnvValues, + ), + }, + workos: { + enabled: remoteWins("auth.third_party.workos.enabled") + ? config.auth.third_party.workos.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", + config.auth.third_party.workos.enabled, + "auth.third_party.workos.enabled", + projectEnvValues, + ), + issuer_url: remoteWins("auth.third_party.workos.issuer_url") + ? config.auth.third_party.workos.issuer_url + : legacyEnvOverride( + "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", + config.auth.third_party.workos.issuer_url, + projectEnvValues, + ), + }, + }; - // The "at most one enabled" + required-field checks `resolveThirdPartyIssuerUrl` - // performs only run while auth is enabled — but this whole - // function is called UNCONDITIONALLY, regardless of - // `auth.enabled`. When auth is enabled, `legacyResolveLocalConfigValues`'s own gated - // `validateAuthThirdPartyProviders`-equivalent check already ran first, so the validating - // resolver here is safe/redundant-but-harmless. When auth is disabled, that earlier validation - // is (correctly) skipped, so this function must NOT re-introduce it — using the unchecked, - // no-throw issuer-url builder instead. - // Same remote-over-env precedence as every other field above — `auth.enabled` is in - // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and an ungated - // `legacyEnvOverrideBool` call THROWS on a malformed `SUPABASE_AUTH_ENABLED` even when a - // matched remote block already set `auth.enabled` at override tier — a value - // validation never even evaluates the env var for in that case — which would otherwise abort - // this whole function (and the shadow's PG15+ one-shot auth-migration job it feeds via - // `legacyBuildLocalDbContainerInputs`) on an env value the override tier should silently ignore - // (review: PRRT_kwDOErm0O86W30n6). - const authEnabled = remoteWins("auth.enabled") - ? config.auth.enabled - : legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, + // The "at most one enabled" + required-field checks `resolveThirdPartyIssuerUrl` + // performs only run while auth is enabled — but this whole + // function is called UNCONDITIONALLY, regardless of + // `auth.enabled`. When auth is enabled, `legacyResolveLocalConfigValues`'s own gated + // `validateAuthThirdPartyProviders`-equivalent check already ran first, so the validating + // resolver here is safe/redundant-but-harmless. When auth is disabled, that earlier validation + // is (correctly) skipped, so this function must NOT re-introduce it — using the unchecked, + // no-throw issuer-url builder instead. + // Same remote-over-env precedence as every other field above — `auth.enabled` is in + // `LEGACY_ENV_OVERRIDABLE_KEYS` (`legacy-db-config.toml-read.ts`) and an ungated + // `legacyEnvOverrideBool` call THROWS on a malformed `SUPABASE_AUTH_ENABLED` even when a + // matched remote block already set `auth.enabled` at override tier — a value + // validation never even evaluates the env var for in that case — which would otherwise abort + // this whole function (and the shadow's PG15+ one-shot auth-migration job it feeds via + // `legacyBuildLocalDbContainerInputs`) on an env value the override tier should silently ignore + // (review: PRRT_kwDOErm0O86W30n6). + const authEnabled = remoteWins("auth.enabled") + ? config.auth.enabled + : legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const issuerUrl = yield* Effect.try({ + try: () => + authEnabled + ? resolveThirdPartyIssuerUrl(thirdParty) + : thirdPartyIssuerUrlUnchecked(thirdParty), + catch: (cause) => + new LegacyConfigValidateError(cause instanceof Error ? cause.message : String(cause)), + }); + + const keys: Array<unknown> = []; + // Only attempt the remote fetch when the issuer URL is non-empty — + // a provider's own issuer-url resolution can return the + // empty string with no validation (e.g. workos's is a raw field read), so an + // enabled-but-unconfigured third-party provider with + // `auth.enabled = false` must be tolerated, not fetched. + if (issuerUrl !== undefined && issuerUrl.length > 0) { + const remoteKeys = yield* resolveRemoteJwks(issuerUrl).pipe( + Effect.mapError((cause) => new LegacyConfigValidateError(cause.message)), ); - let issuerUrl: string | undefined; - if (authEnabled) { - try { - issuerUrl = resolveThirdPartyIssuerUrl(thirdParty); - } catch (cause) { - throw new LegacyConfigValidateError(cause instanceof Error ? cause.message : String(cause)); + keys.push(...remoteKeys); } - } else { - issuerUrl = thirdPartyIssuerUrlUnchecked(thirdParty); - } - - const keys: unknown[] = []; - // Only attempt the remote fetch when the issuer URL is non-empty — - // a provider's own issuer-url resolution can return the - // empty string with no validation (e.g. workos's is a raw field read), so an - // enabled-but-unconfigured third-party provider with - // `auth.enabled = false` must be tolerated, not fetched. - if (issuerUrl !== undefined && issuerUrl.length > 0) { - try { - keys.push(...(await resolveRemoteJwks(issuerUrl))); - } catch (cause) { - throw new LegacyConfigValidateError(cause instanceof Error ? cause.message : String(cause)); + keys.push(...signingKeys.map(toPublicJwk)); + if (signingKeysPath === undefined || signingKeysPath.length === 0) { + keys.push({ kty: "oct", k: Buffer.from(jwtSecret).toString("base64url") }); } - } - keys.push(...signingKeys.map(toPublicJwk)); - if (signingKeysPath === undefined || signingKeysPath.length === 0) { - keys.push({ kty: "oct", k: Buffer.from(jwtSecret).toString("base64url") }); - } - return JSON.stringify({ keys }); + return encodeLegacyJwksDocument({ keys }); + }); } diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts index 9857098974..136075b786 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.unit.test.ts @@ -1,22 +1,21 @@ import { generateKeyPairSync } from "node:crypto"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; -import { Schema } from "effect"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Clock, Data, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { importJWK, jwtVerify } from "jose"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, vi } from "vitest"; import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { LEGACY_DEFAULT_SIGNING_KEY } from "./legacy-go-jwt.ts"; import { LEGACY_POSTGRES_DEFAULT_ROOT_KEY, - LegacyInvalidAnalyticsBackendEnvOverrideError, LegacyInvalidBoolEnvOverrideError, LegacyInvalidEdgeRuntimePolicyEnvOverrideError, LegacyInvalidJwtSecretError, LegacyInvalidPoolModeEnvOverrideError, - LegacyInvalidPortEnvOverrideError, LegacyInvalidRealtimeIpVersionEnvOverrideError, LegacyInvalidSessionReplicationRoleEnvOverrideError, legacyEnvOverrideApiMaxRows, @@ -38,12 +37,86 @@ import { legacyResolveAuthSms, legacyResolveConfiguredSigningKeys, legacyResolveDbSettingsEnvOverrides, - legacyResolveLocalConfigValues, + legacyResolveLocalConfigValues as resolveLegacyLocalConfigValues, legacyResolveLocalJwks, } from "./legacy-local-config-values.ts"; - -const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +import { LegacyConfigValidateError } from "./legacy-config-validate.ts"; + +const decodeConfig = Schema.decodeSync(ProjectConfigSchema); +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +class LegacyTestPromiseError extends Data.TaggedError("LegacyTestPromiseError")<{ + readonly cause: unknown; +}> {} +const tryPromiseEffect = <A>(thunk: () => Promise<A>) => + Effect.tryPromise({ + try: thunk, + catch: (cause) => new LegacyTestPromiseError({ cause }), + }); const WORKDIR = "/tmp/legacy-local-config-values-test"; +const testPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const join = (...parts: ReadonlyArray<string>) => testPath.join(...parts); + +const testProjectEnvValues: Record<string, string> = {}; + +function stubEnv(key: string, value: string | undefined) { + if (value === undefined) { + delete testProjectEnvValues[key]; + } else { + testProjectEnvValues[key] = value; + } +} + +function resolveLocalConfigValues( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly<Record<string, string>> | undefined = testProjectEnvValues, + document?: Readonly<Record<string, unknown>>, + remoteOverrideKeys?: ReadonlySet<string>, + projectIdFallback?: string, +) { + return resolveLegacyLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues ?? testProjectEnvValues, + document, + remoteOverrideKeys, + projectIdFallback, + ).pipe( + Effect.provide(BunServices.layer), + Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), + Effect.runSync, + ); +} + +function resolveLocalConfigValuesEffect( + config: ProjectConfig, + hostname: string, + workdir: string, + projectEnvValues: Readonly<Record<string, string>> | undefined = testProjectEnvValues, + document?: Readonly<Record<string, unknown>>, + remoteOverrideKeys?: ReadonlySet<string>, + projectIdFallback?: string, +) { + return resolveLegacyLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues ?? testProjectEnvValues, + document, + remoteOverrideKeys, + projectIdFallback, + ).pipe( + Effect.provide(BunServices.layer), + Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), + ); +} + +afterEach(() => { + for (const key of Object.keys(testProjectEnvValues)) delete testProjectEnvValues[key]; +}); function baseConfig(overrides: Record<string, unknown> = {}): ProjectConfig { return decodeConfig({ project_id: "test", ...overrides }); @@ -57,15 +130,49 @@ function generateRsaJwk(): Record<string, unknown> { } function writeSigningKeys(workdir: string, jwks: ReadonlyArray<Record<string, unknown>>) { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), JSON.stringify(jwks)); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const supabaseDir = join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(join(supabaseDir, "signing_keys.json"), encodeJson(jwks)); + }).pipe(Effect.provide(BunServices.layer)); +} + +function writeFileEffect(path: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }).pipe(Effect.provide(BunServices.layer)); +} + +function makeDirectoryEffect(path: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); } describe("legacyResolveLocalConfigValues", () => { + it.effect("reports malformed env overrides through the typed config-validation channel", () => + Effect.gen(function* () { + stubEnv("SUPABASE_API_PORT", "not-a-port"); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(baseConfig(), "127.0.0.1", WORKDIR), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Exit.findErrorOption(exit))).toBeInstanceOf( + LegacyConfigValidateError, + ); + expect(Cause.hasDies(exit.cause)).toBe(false); + } + }), + ); + it("derives every URL from api.external_url when unset", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.apiUrl).toBe("http://127.0.0.1:54321"); expect(values.restUrl).toBe("http://127.0.0.1:54321/rest/v1"); @@ -79,32 +186,32 @@ describe("legacyResolveLocalConfigValues", () => { it("uses https and the configured port when api.tls.enabled", () => { const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.apiUrl).toBe("https://127.0.0.1:54321"); }); it("uses api.external_url verbatim when configured", () => { const config = baseConfig({ api: { external_url: "https://example.test" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.apiUrl).toBe("https://example.test"); expect(values.restUrl).toBe("https://example.test/rest/v1"); }); it("brackets an IPv6 hostname when building host:port", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "::1", WORKDIR); + const values = resolveLocalConfigValues(config, "::1", WORKDIR); expect(values.apiUrl).toBe("http://[::1]:54321"); }); it("builds the db URL with the hardcoded postgres password", () => { const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); }); it("falls back to the default JWT secret and opaque keys when unset", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.jwtSecret).toBe("super-secret-jwt-token-with-at-least-32-characters-long"); expect(values.publishableKey).toBe("sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH"); expect(values.secretKey).toBe("sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"); @@ -114,22 +221,22 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ auth: { publishable_key: "sb_publishable_custom", secret_key: "sb_secret_custom" }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.publishableKey).toBe("sb_publishable_custom"); expect(values.secretKey).toBe("sb_secret_custom"); }); it("signs the default anon/service_role JWTs from the resolved secret", () => { const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); // Byte-exact Go-parity shape is covered by legacy-go-jwt.unit.test.ts; here we // only assert the resolver wires the default secret through to both roles. const [, anonPayload] = values.anonKey.split("."); const [, serviceRolePayload] = values.serviceRoleKey.split("."); - expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ + expect(decodeJson(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ role: "anon", }); - expect(JSON.parse(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( + expect(decodeJson(Buffer.from(serviceRolePayload ?? "", "base64url").toString())).toMatchObject( { role: "service_role" }, ); }); @@ -138,14 +245,14 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig({ auth: { anon_key: "configured-anon", service_role_key: "configured-service-role" }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.anonKey).toBe("configured-anon"); expect(values.serviceRoleKey).toBe("configured-service-role"); }); it("signs anon/service_role JWTs from a configured jwt_secret", () => { const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.jwtSecret).toBe("a".repeat(32)); expect(values.anonKey).not.toBe(""); }); @@ -155,7 +262,7 @@ describe("legacyResolveLocalConfigValues", () => { // can render output — reproduced as a thrown // error here rather than silently signing with the too-short secret. const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( LegacyInvalidJwtSecretError, ); }); @@ -168,23 +275,23 @@ describe("legacyResolveLocalConfigValues", () => { "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; afterEach(() => { - delete process.env["DOTENV_PRIVATE_KEY"]; + stubEnv("DOTENV_PRIVATE_KEY", undefined); }); it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { // "value" is only 5 characters, shorter than Go's minimum JWT secret length, // so pad it out the way a real deployment's decrypted secret would be sized. - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( LegacyInvalidJwtSecretError, ); }); it("decrypts an encrypted: publishable_key when DOTENV_PRIVATE_KEY is set", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.publishableKey).toBe("value"); }); @@ -192,2833 +299,3076 @@ describe("legacyResolveLocalConfigValues", () => { // Go aborts the whole command with `failed to parse config: <error>` rather // than silently using the ciphertext as literal key material. const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "failed to parse config: missing private key", ); }); it("decrypts an encrypted: auth.email.smtp.pass, matching Go's Secret-typed Smtp.Pass field", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const document = { auth: { email: { smtp: { enabled: true, pass: VAULT_ENCRYPTED } } } }; - const resolved = legacyResolveAuthEmailSmtp(document.auth, undefined); + const resolved = legacyResolveAuthEmailSmtp(document.auth, testProjectEnvValues); expect(resolved?.pass).toBe("value"); }); it("decrypts an encrypted: studio.openai_api_key, matching Go's Secret-typed OpenaiApiKey field", () => { - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; + stubEnv("DOTENV_PRIVATE_KEY", VAULT_PRIVATE_KEY); const config = baseConfig({ studio: { openai_api_key: VAULT_ENCRYPTED } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.openaiApiKey).toBe("value"); }); it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { // Go's decrypt hook runs on whatever value reaches the config.Secret field, // whether it was sourced from config.toml or a Viper env override. - process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + DOTENV_PRIVATE_KEY: VAULT_PRIVATE_KEY, SUPABASE_AUTH_SECRET_KEY: VAULT_ENCRYPTED, }); expect(values.secretKey).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; + stubEnv("DOTENV_PRIVATE_KEY", undefined); + }); + + it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { + // Go's Config.Validate checks ProjectId first, before any other field. + // The workdir-basename default is merged + // in as a viper default BEFORE config.toml is merged, so an explicit + // `project_id = ""` in the file overwrites that default with the literal + // empty string rather than being treated as absent — Go fails outright. + const config = baseConfig({ project_id: "" }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: project_id", + ); }); - }); - it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { - // Go's Config.Validate checks ProjectId first, before any other field. - // The workdir-basename default is merged - // in as a viper default BEFORE config.toml is merged, so an explicit - // `project_id = ""` in the file overwrites that default with the literal - // empty string rather than being treated as absent — Go fails outright. - const config = baseConfig({ project_id: "" }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: project_id", - ); - }); + it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { + const config = Schema.decodeSync(ProjectConfigSchema)({}); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("does not reject an absent project_id when the workdir basename sanitizes to a non-empty value", () => { - const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { + // `mergeDefaultValues` merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper + // DEFAULT before config.toml is merged — + // so `c.ProjectId` is never Go's zero value by the time `Validate` runs. A workdir whose + // basename sanitizes to `""` (every character invalid, e.g. `!!!`) therefore still fails + // config loading in Go even with no `project_id` key in the file at all. + const config = Schema.decodeSync(ProjectConfigSchema)({}); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( + "Missing required field in config: project_id", + ); + }); - it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { - // `mergeDefaultValues` merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper - // DEFAULT before config.toml is merged — - // so `c.ProjectId` is never Go's zero value by the time `Validate` runs. A workdir whose - // basename sanitizes to `""` (every character invalid, e.g. `!!!`) therefore still fails - // config loading in Go even with no `project_id` key in the file at all. - const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( - "Missing required field in config: project_id", - ); - }); + it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { + const config = Schema.decodeSync(ProjectConfigSchema)({}); + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); - it("lets SUPABASE_PROJECT_ID override an absent project_id whose basename sanitizes to empty", () => { - const config = Schema.decodeUnknownSync(ProjectConfigSchema)({}); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!", { - SUPABASE_PROJECT_ID: "env-project", - }), - ).not.toThrow(); - }); + it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { + // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence + // than config.toml, so a non-empty env override must + // win even when the file's project_id is explicitly empty. + const config = baseConfig({ project_id: "" }); + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { + SUPABASE_PROJECT_ID: "env-project", + }), + ).not.toThrow(); + }); - it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { - // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence - // than config.toml, so a non-empty env override must - // win even when the file's project_id is explicitly empty. - const config = baseConfig({ project_id: "" }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { - SUPABASE_PROJECT_ID: "env-project", - }), - ).not.toThrow(); - }); + it("hardcodes the Go-parity local S3 credentials", () => { + const config = baseConfig(); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); + expect(values.storageS3SecretAccessKey).toBe( + "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", + ); + expect(values.storageS3Region).toBe("local"); + }); - it("hardcodes the Go-parity local S3 credentials", () => { - const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); - expect(values.storageS3SecretAccessKey).toBe( - "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907", - ); - expect(values.storageS3Region).toBe("local"); - }); + describe("SUPABASE_AUTH_* env overrides", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-env-override-test-"); - describe("SUPABASE_AUTH_* env overrides", () => { - const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-env-override-test-"); + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() — + // env vars take precedence over config.toml. + const ENV_KEYS = [ + "SUPABASE_AUTH_JWT_SECRET", + "SUPABASE_AUTH_PUBLISHABLE_KEY", + "SUPABASE_AUTH_SECRET_KEY", + "SUPABASE_AUTH_ANON_KEY", + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "SUPABASE_AUTH_SIGNING_KEYS_PATH", + ] as const; - // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() — - // env vars take precedence over config.toml. - const ENV_KEYS = [ - "SUPABASE_AUTH_JWT_SECRET", - "SUPABASE_AUTH_PUBLISHABLE_KEY", - "SUPABASE_AUTH_SECRET_KEY", - "SUPABASE_AUTH_ANON_KEY", - "SUPABASE_AUTH_SERVICE_ROLE_KEY", - "SUPABASE_AUTH_SIGNING_KEYS_PATH", - ] as const; + afterEach(() => { + for (const key of ENV_KEYS) stubEnv(key, undefined); + }); - afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - }); + it("overrides jwt_secret even when config.toml sets one", () => { + stubEnv("SUPABASE_AUTH_JWT_SECRET", "b".repeat(32)); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("b".repeat(32)); + }); - it("overrides jwt_secret even when config.toml sets one", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "b".repeat(32); - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("b".repeat(32)); - }); + it("overrides publishable_key/secret_key", () => { + stubEnv("SUPABASE_AUTH_PUBLISHABLE_KEY", "env-publishable"); + stubEnv("SUPABASE_AUTH_SECRET_KEY", "env-secret"); + const config = baseConfig({ + auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.publishableKey).toBe("env-publishable"); + expect(values.secretKey).toBe("env-secret"); + }); - it("overrides publishable_key/secret_key", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "env-publishable"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "env-secret"; - const config = baseConfig({ - auth: { publishable_key: "config-publishable", secret_key: "config-secret" }, + it("overrides anon_key/service_role_key", () => { + stubEnv("SUPABASE_AUTH_ANON_KEY", "env-anon"); + stubEnv("SUPABASE_AUTH_SERVICE_ROLE_KEY", "env-service-role"); + const config = baseConfig({ + auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.anonKey).toBe("env-anon"); + expect(values.serviceRoleKey).toBe("env-service-role"); }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.publishableKey).toBe("env-publishable"); - expect(values.secretKey).toBe("env-secret"); - }); - it("overrides anon_key/service_role_key", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role"; - const config = baseConfig({ - auth: { anon_key: "config-anon", service_role_key: "config-service-role" }, + it("treats an empty env var as unset, matching Viper's default", () => { + stubEnv("SUPABASE_AUTH_JWT_SECRET", ""); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("a".repeat(32)); }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.anonKey).toBe("env-anon"); - expect(values.serviceRoleKey).toBe("env-service-role"); - }); - it("treats an empty env var as unset, matching Viper's default", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("a".repeat(32)); - }); + it("still applies the short-secret validation to an env-provided jwt_secret", () => { + stubEnv("SUPABASE_AUTH_JWT_SECRET", "too-short"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyInvalidJwtSecretError, + ); + }); - it("still applies the short-secret validation to an env-provided jwt_secret", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "too-short"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidJwtSecretError, + it.effect("overrides signing_keys_path even when config.toml doesn't set one", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "signing_keys.json"); + const config = baseConfig(); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = yield* tryPromiseEffect(() => importJWK(publicJwk, "RS256")); + const { protectedHeader } = yield* tryPromiseEffect(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + }), + ); + + it.effect("prefers an env-provided signing_keys_path over config.toml's", () => + Effect.gen(function* () { + const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; + const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; + yield* writeSigningKeys(tempRoot.current, [envJwk]); + const supabaseDir = join(tempRoot.current, "supabase"); + yield* writeFileEffect(join(supabaseDir, "other_keys.json"), encodeJson([configJwk])); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + const [header] = values.anonKey.split("."); + expect(decodeJson(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ + kid: "env-kid", + }); + }), ); }); - it("overrides signing_keys_path even when config.toml doesn't set one", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; - const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { + // `LoadEnvHook` is + // the first mapstructure decode hook composed into `v.UnmarshalExact`, + // so it resolves a nested `env(VAR)` + // reference on ANY string mapstructure decodes into the struct — including + // a `SUPABASE_*` env-override value itself, not just a `config.toml` + // literal. `envOverride`'s callers (string/port/bool fields) must all see + // that same resolution. + const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; - const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; - const publicKey = await importJWK(publicJwk, "RS256"); - const { protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - }); + afterEach(() => { + for (const key of ENV_KEYS) stubEnv(key, undefined); + stubEnv("INDIRECT_JWT_SECRET", undefined); + stubEnv("INDIRECT_DB_PORT", undefined); + stubEnv("INDIRECT_API_ENABLED", undefined); + }); - it("prefers an env-provided signing_keys_path over config.toml's", () => { - const envJwk = { ...generateRsaJwk(), kid: "env-kid" }; - const configJwk = { ...generateRsaJwk(), kid: "config-kid" }; - writeSigningKeys(tempRoot.current, [envJwk]); - const supabaseDir = join(tempRoot.current, "supabase"); - writeFileSync(join(supabaseDir, "other_keys.json"), JSON.stringify([configJwk])); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "signing_keys.json"; - const config = baseConfig({ auth: { signing_keys_path: "other_keys.json" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - const [header] = values.anonKey.split("."); - expect(JSON.parse(Buffer.from(header ?? "", "base64url").toString())).toMatchObject({ - kid: "env-kid", + it("resolves a string override's env(VAR) indirection", () => { + stubEnv("SUPABASE_AUTH_JWT_SECRET", "env(INDIRECT_JWT_SECRET)"); + stubEnv("INDIRECT_JWT_SECRET", "c".repeat(32)); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.jwtSecret).toBe("c".repeat(32)); }); - }); - }); - describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { - // `LoadEnvHook` is - // the first mapstructure decode hook composed into `v.UnmarshalExact`, - // so it resolves a nested `env(VAR)` - // reference on ANY string mapstructure decodes into the struct — including - // a `SUPABASE_*` env-override value itself, not just a `config.toml` - // literal. `envOverride`'s callers (string/port/bool fields) must all see - // that same resolution. - const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; + it("resolves a port override's env(VAR) indirection", () => { + stubEnv("SUPABASE_DB_PORT", "env(INDIRECT_DB_PORT)"); + stubEnv("INDIRECT_DB_PORT", "54329"); + const config = baseConfig({ db: { port: 54322 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + }); - afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - delete process.env["INDIRECT_JWT_SECRET"]; - delete process.env["INDIRECT_DB_PORT"]; - delete process.env["INDIRECT_API_ENABLED"]; - }); + it("resolves a bool override's env(VAR) indirection", () => { + stubEnv("SUPABASE_API_ENABLED", "env(INDIRECT_API_ENABLED)"); + stubEnv("INDIRECT_API_ENABLED", "false"); + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + // If the bool override weren't resolved through the indirection, the + // literal "env(INDIRECT_API_ENABLED)" string would fail Go's + // strconv.ParseBool acceptance set and throw LegacyInvalidBoolEnvOverrideError; + // resolving it to "false" disables api.enabled and skips the TLS check + // that would otherwise throw on the missing cert file. + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("resolves a string override's env(VAR) indirection", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; - process.env["INDIRECT_JWT_SECRET"] = "c".repeat(32); - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.jwtSecret).toBe("c".repeat(32)); + it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { + stubEnv("SUPABASE_AUTH_JWT_SECRET", "env(INDIRECT_JWT_SECRET)"); + const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + // Go's LoadEnvHook only substitutes when the target var is non-empty — + // an unset indirection leaves the literal + // `env(VAR)` string, same as an unresolved config.toml-level reference. + expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); + }); }); - it("resolves a port override's env(VAR) indirection", () => { - process.env["SUPABASE_DB_PORT"] = "env(INDIRECT_DB_PORT)"; - process.env["INDIRECT_DB_PORT"] = "54329"; - const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); - }); + describe("non-auth SUPABASE_* env overrides", () => { + // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() + // generically across the whole config struct, + // not just auth fields — this is also exercised against + // auth.site_url, and status.go's toValues() reads the already-overridden + // utils.Config.* directly, so every port/URL status derives must honor the + // same override. + const ENV_KEYS = [ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + "SUPABASE_API_EXTERNAL_URL", + "SUPABASE_STUDIO_API_URL", + ] as const; - it("resolves a bool override's env(VAR) indirection", () => { - process.env["SUPABASE_API_ENABLED"] = "env(INDIRECT_API_ENABLED)"; - process.env["INDIRECT_API_ENABLED"] = "false"; - const config = baseConfig({ - api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - // If the bool override weren't resolved through the indirection, the - // literal "env(INDIRECT_API_ENABLED)" string would fail Go's - // strconv.ParseBool acceptance set and throw LegacyInvalidBoolEnvOverrideError; - // resolving it to "false" disables api.enabled and skips the TLS check - // that would otherwise throw on the missing cert file. - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + afterEach(() => { + for (const key of ENV_KEYS) stubEnv(key, undefined); + }); - it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; - const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - // Go's LoadEnvHook only substitutes when the target var is non-empty — - // an unset indirection leaves the literal - // `env(VAR)` string, same as an unresolved config.toml-level reference. - expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); - }); - }); + it("overrides db.port for the derived DB URL and the exposed dbPort", () => { + stubEnv("SUPABASE_DB_PORT", "54329"); + const config = baseConfig({ db: { port: 54322 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); + expect(values.dbPort).toBe(54329); + }); - describe("non-auth SUPABASE_* env overrides", () => { - // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() - // generically across the whole config struct, - // not just auth fields — this is also exercised against - // auth.site_url, and status.go's toValues() reads the already-overridden - // utils.Config.* directly, so every port/URL status derives must honor the - // same override. - const ENV_KEYS = [ - "SUPABASE_DB_PORT", - "SUPABASE_STUDIO_PORT", - "SUPABASE_LOCAL_SMTP_PORT", - "SUPABASE_API_PORT", - "SUPABASE_API_EXTERNAL_URL", - "SUPABASE_STUDIO_API_URL", - ] as const; + it("overrides studio.port for the derived Studio URL", () => { + stubEnv("SUPABASE_STUDIO_PORT", "54330"); + const config = baseConfig({ studio: { port: 54323 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.studioUrl).toBe("http://127.0.0.1:54330"); + }); - afterEach(() => { - for (const key of ENV_KEYS) delete process.env[key]; - }); + it("overrides local_smtp.port for the derived Mailpit URL", () => { + stubEnv("SUPABASE_LOCAL_SMTP_PORT", "54331"); + const config = baseConfig({ local_smtp: { port: 54324 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); + }); - it("overrides db.port for the derived DB URL and the exposed dbPort", () => { - process.env["SUPABASE_DB_PORT"] = "54329"; - const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54329/postgres"); - expect(values.dbPort).toBe(54329); - }); + it("overrides api.port for every API-derived URL", () => { + stubEnv("SUPABASE_API_PORT", "54332"); + const config = baseConfig({ api: { port: 54321 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54332"); + expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); + }); - it("overrides studio.port for the derived Studio URL", () => { - process.env["SUPABASE_STUDIO_PORT"] = "54330"; - const config = baseConfig({ studio: { port: 54323 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.studioUrl).toBe("http://127.0.0.1:54330"); - }); + it("overrides api.external_url even when config.toml sets one", () => { + stubEnv("SUPABASE_API_EXTERNAL_URL", "https://env-override.example"); + const config = baseConfig({ api: { external_url: "https://config.example" } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://env-override.example"); + }); - it("overrides local_smtp.port for the derived Mailpit URL", () => { - process.env["SUPABASE_LOCAL_SMTP_PORT"] = "54331"; - const config = baseConfig({ local_smtp: { port: 54324 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.mailpitUrl).toBe("http://127.0.0.1:54331"); - }); + it("treats an empty non-auth env var as unset, matching Viper's default", () => { + stubEnv("SUPABASE_DB_PORT", ""); + const config = baseConfig({ db: { port: 54322 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); + }); - it("overrides api.port for every API-derived URL", () => { - process.env["SUPABASE_API_PORT"] = "54332"; - const config = baseConfig({ api: { port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://127.0.0.1:54332"); - expect(values.restUrl).toBe("http://127.0.0.1:54332/rest/v1"); - }); + // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via + // Viper's UnmarshalExact (WeaklyTypedInput + // decodes the override string with strconv.ParseUint and hard-fails on a + // malformed value) rather than silently producing a `NaN`-laced URL. + it.each([ + "SUPABASE_DB_PORT", + "SUPABASE_STUDIO_PORT", + "SUPABASE_LOCAL_SMTP_PORT", + "SUPABASE_API_PORT", + ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { + stubEnv(envKey, "abc"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyConfigValidateError, + ); + }); - it("overrides api.external_url even when config.toml sets one", () => { - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-override.example"; - const config = baseConfig({ api: { external_url: "https://config.example" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://env-override.example"); - }); + it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { + stubEnv("SUPABASE_DB_PORT", "99999"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyConfigValidateError, + ); + }); - it("treats an empty non-auth env var as unset, matching Viper's default", () => { - process.env["SUPABASE_DB_PORT"] = ""; - const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); - }); + // Go's `strconv.ParseUint(str, 0, 16)` (base 0) auto-detects octal/hex/binary literals for + // `SUPABASE_*_PORT` overrides too — same base-0 grammar as `legacyEnvOverrideUint` + // (`parseGoBaseZeroUint`), just capped at `uint16` instead of `uint64`. The old + // `/^\d+$/`-plus-`Number()` parsing silently misread a bare-leading-zero override as decimal + // instead of octal, and rejected a `0x`-prefixed override outright even though Go accepts it. + it("resolves an octal leading-zero SUPABASE_DB_PORT override to its octal value, not decimal", () => { + stubEnv("SUPABASE_DB_PORT", "010"); + const config = baseConfig({ db: { port: 54322 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbPort).toBe(8); + expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:8/postgres"); + }); - // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via - // Viper's UnmarshalExact (WeaklyTypedInput - // decodes the override string with strconv.ParseUint and hard-fails on a - // malformed value) rather than silently producing a `NaN`-laced URL. - it.each([ - "SUPABASE_DB_PORT", - "SUPABASE_STUDIO_PORT", - "SUPABASE_LOCAL_SMTP_PORT", - "SUPABASE_API_PORT", - ] as const)("rejects a malformed %s override instead of producing NaN", (envKey) => { - process.env[envKey] = "abc"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidPortEnvOverrideError, - ); - }); + it("resolves a 0x-prefixed SUPABASE_DB_PORT override as hex", () => { + stubEnv("SUPABASE_DB_PORT", "0x1F90"); + const config = baseConfig({ db: { port: 54322 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbPort).toBe(8080); + }); - it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { - process.env["SUPABASE_DB_PORT"] = "99999"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidPortEnvOverrideError, - ); - }); + it("still resolves a plain decimal SUPABASE_DB_PORT override with no leading zero", () => { + stubEnv("SUPABASE_DB_PORT", "5432"); + const config = baseConfig({ db: { port: 54322 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.dbPort).toBe(5432); + }); - // Go's `strconv.ParseUint(str, 0, 16)` (base 0) auto-detects octal/hex/binary literals for - // `SUPABASE_*_PORT` overrides too — same base-0 grammar as `legacyEnvOverrideUint` - // (`parseGoBaseZeroUint`), just capped at `uint16` instead of `uint64`. The old - // `/^\d+$/`-plus-`Number()` parsing silently misread a bare-leading-zero override as decimal - // instead of octal, and rejected a `0x`-prefixed override outright even though Go accepts it. - it("resolves an octal leading-zero SUPABASE_DB_PORT override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_PORT"] = "010"; - const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(8); - expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:8/postgres"); - }); + it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the uint16 range", () => { + stubEnv("SUPABASE_DB_PORT", "0x1FFFF"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyConfigValidateError, + ); + }); - it("resolves a 0x-prefixed SUPABASE_DB_PORT override as hex", () => { - process.env["SUPABASE_DB_PORT"] = "0x1F90"; - const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(8080); - }); + // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, + // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a + // Config.Validate-time hard-fail specific to db.port: it has no `enabled` + // gate in Go, unlike api.port/studio.port/local_smtp.port. + it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { + stubEnv("SUPABASE_DB_PORT", "0"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: db.port", + ); + }); - it("still resolves a plain decimal SUPABASE_DB_PORT override with no leading zero", () => { - process.env["SUPABASE_DB_PORT"] = "5432"; - const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.dbPort).toBe(5432); - }); + // Unlike db.port, Go gates the api.port===0 rejection on api.enabled — + // api.enabled defaults to true, so a + // configured or env-overridden zero port is rejected by default. + it("rejects a configured api.port of 0 when api is enabled", () => { + const config = baseConfig({ api: { port: 0 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); - it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the uint16 range", () => { - process.env["SUPABASE_DB_PORT"] = "0x1FFFF"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidPortEnvOverrideError, - ); - }); + it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { + stubEnv("SUPABASE_API_PORT", "0"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: api.port", + ); + }); - // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, - // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a - // Config.Validate-time hard-fail specific to db.port: it has no `enabled` - // gate in Go, unlike api.port/studio.port/local_smtp.port. - it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { - process.env["SUPABASE_DB_PORT"] = "0"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: db.port", - ); - }); + it("does not reject a zero api.port when api is disabled", () => { + const config = baseConfig({ api: { enabled: false, port: 0 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - // Unlike db.port, Go gates the api.port===0 rejection on api.enabled — - // api.enabled defaults to true, so a - // configured or env-overridden zero port is rejected by default. - it("rejects a configured api.port of 0 when api is enabled", () => { - const config = baseConfig({ api: { port: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: api.port", - ); - }); + // Go gates the studio.port===0 rejection on studio.enabled, + // same pattern as api.port above. + // studio.enabled defaults to true, so a configured or env-overridden zero + // port is rejected by default. + it("rejects a configured studio.port of 0 when studio is enabled", () => { + const config = baseConfig({ studio: { port: 0 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); - it("rejects a zero SUPABASE_API_PORT override when api is enabled", () => { - process.env["SUPABASE_API_PORT"] = "0"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: api.port", - ); - }); + it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { + stubEnv("SUPABASE_STUDIO_PORT", "0"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: studio.port", + ); + }); - it("does not reject a zero api.port when api is disabled", () => { - const config = baseConfig({ api: { enabled: false, port: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("does not reject a zero studio.port when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, port: 0 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - // Go gates the studio.port===0 rejection on studio.enabled, - // same pattern as api.port above. - // studio.enabled defaults to true, so a configured or env-overridden zero - // port is rejected by default. - it("rejects a configured studio.port of 0 when studio is enabled", () => { - const config = baseConfig({ studio: { port: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: studio.port", - ); - }); + // Go's Config.Validate parses studio.api_url with net/url.Parse right + // after the port check, still inside `if c.Studio.Enabled`. + it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { + const config = baseConfig({ studio: { api_url: "http://[::1" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); - it("rejects a zero SUPABASE_STUDIO_PORT override when studio is enabled", () => { - process.env["SUPABASE_STUDIO_PORT"] = "0"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: studio.port", - ); - }); + it("does not reject a malformed studio.api_url when studio is disabled", () => { + const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("does not reject a zero studio.port when studio is disabled", () => { - const config = baseConfig({ studio: { enabled: false, port: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("does not throw for the default studio.api_url", () => { + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - // Go's Config.Validate parses studio.api_url with net/url.Parse right - // after the port check, still inside `if c.Studio.Enabled`. - it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { - const config = baseConfig({ studio: { api_url: "http://[::1" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, - ); - }); + it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { + stubEnv("SUPABASE_STUDIO_API_URL", "http://[::1"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, + ); + }); - it("does not reject a malformed studio.api_url when studio is disabled", () => { - const config = baseConfig({ studio: { enabled: false, api_url: "http://[::1" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's + // struct field is still named `Inbucket` for the `[local_smtp]` TOML + // section), same pattern as api.port/ + // studio.port above. local_smtp.enabled defaults to true, so a configured + // or env-overridden zero port is rejected by default. + it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { + const config = baseConfig({ local_smtp: { port: 0 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); - it("does not throw for the default studio.api_url", () => { - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { + stubEnv("SUPABASE_LOCAL_SMTP_PORT", "0"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: local_smtp.port", + ); + }); - it("rejects a malformed SUPABASE_STUDIO_API_URL override", () => { - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - `Invalid config for studio.api_url: parse "http://[::1": missing ']' in host`, - ); + it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { + const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); - // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's - // struct field is still named `Inbucket` for the `[local_smtp]` TOML - // section), same pattern as api.port/ - // studio.port above. local_smtp.enabled defaults to true, so a configured - // or env-overridden zero port is rejected by default. - it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { - const config = baseConfig({ local_smtp: { port: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: local_smtp.port", - ); - }); + describe("db.major_version (required field in config)", () => { + // The pure 0/12/13-17/generic-invalid assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. + afterEach(() => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", undefined); + }); - it("rejects a zero SUPABASE_LOCAL_SMTP_PORT override when local_smtp is enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_PORT"] = "0"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: local_smtp.port", - ); - }); + it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "15"); + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("does not reject a zero local_smtp.port when local_smtp is disabled", () => { - const config = baseConfig({ local_smtp: { enabled: false, port: 0 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); + it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "16"); + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: 16.", + ); + }); - describe("db.major_version (required field in config)", () => { - // The pure 0/12/13-17/generic-invalid assertions moved to - // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — - // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. - afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; - }); + it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "abc"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid db.major_version: abc.", + ); + }); - it("overrides a valid configured major_version via SUPABASE_DB_MAJOR_VERSION", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", ""); + const config = baseConfig({ db: { major_version: 17 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); - it("rejects an unsupported SUPABASE_DB_MAJOR_VERSION override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "16"; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid db.major_version: 16.", - ); - }); + describe("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED env override", () => { + // `[db.network_restrictions]` ships uncommented in Go's default template and + // `NetworkRestrictions` is a plain, non-pointer `db` struct field, so Viper always registers a + // default and decodes this override unconditionally during `Config.Load` — same bucket as + // `db.port`/`db.major_version` above, validated eagerly rather than skipped. + afterEach(() => { + stubEnv("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", undefined); + }); - it("rejects a non-numeric SUPABASE_DB_MAJOR_VERSION override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid db.major_version: abc.", - ); - }); + it("does not throw for a valid SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { + stubEnv("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; - const config = baseConfig({ db: { major_version: 17 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + it("rejects a malformed SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { + stubEnv("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", "notabool"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for db.network_restrictions.enabled: cannot parse "notabool" as a bool', + ); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for db.network_restrictions.enabled: cannot parse "notabool" as a bool', + ); + }); }); - }); - describe("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED env override", () => { - // `[db.network_restrictions]` ships uncommented in Go's default template and - // `NetworkRestrictions` is a plain, non-pointer `db` struct field, so Viper always registers a - // default and decodes this override unconditionally during `Config.Load` — same bucket as - // `db.port`/`db.major_version` above, validated eagerly rather than skipped. - afterEach(() => { - delete process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"]; - }); + describe("db.root_key (unmodeled raw-document field)", () => { + it("falls back to the default root key when absent", () => { + const config = baseConfig(); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.rootKey).toBe(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); + }); - it("does not throw for a valid SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("uses a configured string root_key verbatim", () => { + const config = baseConfig(); + const document = { db: { root_key: "custom-root-key" } }; + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document); + expect(values.rootKey).toBe("custom-root-key"); + }); - it("rejects a malformed SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED override", () => { - process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"] = "notabool"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for db.network_restrictions.enabled: cannot parse "notabool" as a bool', - ); + it("rejects a non-string root_key (e.g. a bare TOML integer), matching Go's Secret decode failure", () => { + // `db.root_key` isn't modeled in `@supabase/config`'s schema, so Go's own + // decode failure (mapstructure rejecting a scalar into the `Secret` struct) + // must be reproduced here rather than letting the raw + // number flow unguarded into `envOverride`/`legacyDecryptAuthSecret`. + const config = baseConfig(); + const document = { db: { root_key: 12345 } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow( + "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", + ); + }); }); - }); - describe("db.root_key (unmodeled raw-document field)", () => { - it("falls back to the default root key when absent", () => { - const config = baseConfig(); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.rootKey).toBe(LEGACY_POSTGRES_DEFAULT_ROOT_KEY); - }); + // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] + // key right after db.major_version, unconditionally — there is no + // storage.enabled-style gate. + // + // Moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls) — this section has no L-specific derivation or env-override mechanics of its own. + + // Go's Config.Validate rejects an invalid edge_runtime.deno_version + // unconditionally — NOT gated on edge_runtime.enabled. + describe("edge_runtime.deno_version (required field in config)", () => { + // The pure 0/1/2/generic-invalid/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. + afterEach(() => { + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", undefined); + }); - it("uses a configured string root_key verbatim", () => { - const config = baseConfig(); - const document = { db: { root_key: "custom-root-key" } }; - const values = legacyResolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - document, - ); - expect(values.rootKey).toBe("custom-root-key"); - }); + it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", "0"); + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: edge_runtime.deno_version", + ); + }); - it("rejects a non-string root_key (e.g. a bare TOML integer), matching Go's Secret decode failure", () => { - // `db.root_key` isn't modeled in `@supabase/config`'s schema, so Go's own - // decode failure (mapstructure rejecting a scalar into the `Secret` struct) - // must be reproduced here rather than letting the raw - // number flow unguarded into `envOverride`/`legacyDecryptAuthSecret`. - const config = baseConfig(); - const document = { db: { root_key: 12345 } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", - ); - }); - }); - - // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] - // key right after db.major_version, unconditionally — there is no - // storage.enabled-style gate. - // - // Moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` - // calls) — this section has no L-specific derivation or env-override mechanics of its own. - - // Go's Config.Validate rejects an invalid edge_runtime.deno_version - // unconditionally — NOT gated on edge_runtime.enabled. - describe("edge_runtime.deno_version (required field in config)", () => { - // The pure 0/1/2/generic-invalid/disabled assertions moved to - // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — - // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. - afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - }); + it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", "3"); + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: 3.", + ); + }); - it("rejects a zero SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "0"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: edge_runtime.deno_version", - ); - }); + it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", "abc"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Failed reading config: Invalid edge_runtime.deno_version: abc.", + ); + }); - it("rejects an unsupported SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "3"; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: 3.", - ); + it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", ""); + const config = baseConfig({ edge_runtime: { deno_version: 2 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); - it("rejects a non-numeric SUPABASE_EDGE_RUNTIME_DENO_VERSION override", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Failed reading config: Invalid edge_runtime.deno_version: abc.", - ); - }); + describe("analytics (BigQuery backend required fields)", () => { + // `Config.Validate` validates `[analytics]` right after + // `edge_runtime.deno_version`: when + // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP + // fields are required, checked in that order. + // + // The pure required-field/complete/disabled assertions moved to + // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — + // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. + afterEach(() => { + stubEnv("SUPABASE_ANALYTICS_ENABLED", undefined); + stubEnv("SUPABASE_ANALYTICS_BACKEND", undefined); + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", undefined); + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", undefined); + stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", undefined); + }); - it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; - const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); + it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { + stubEnv("SUPABASE_ANALYTICS_ENABLED", "true"); + const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); - describe("analytics (BigQuery backend required fields)", () => { - // `Config.Validate` validates `[analytics]` right after - // `edge_runtime.deno_version`: when - // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP - // fields are required, checked in that order. - // - // The pure required-field/complete/disabled assertions moved to - // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — - // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. - afterEach(() => { - delete process.env["SUPABASE_ANALYTICS_ENABLED"]; - delete process.env["SUPABASE_ANALYTICS_BACKEND"]; - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; - }); + it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { + stubEnv("SUPABASE_ANALYTICS_BACKEND", "bigquery"); + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: analytics.gcp_project_id", + ); + }); - it("rejects a bigquery backend enabled only via SUPABASE_ANALYTICS_ENABLED", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "true"; - const config = baseConfig({ analytics: { enabled: false, backend: "bigquery" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: analytics.gcp_project_id", - ); - }); + it("accepts env-provided GCP fields overriding empty config.toml values", () => { + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", "proj"); + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", "123"); + stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", "gcp.json"); + const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("rejects a bigquery backend selected only via SUPABASE_ANALYTICS_BACKEND", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "bigquery"; - const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: analytics.gcp_project_id", - ); + // `LogflareBackend.UnmarshalText` hard-rejects any + // `analytics.backend` value outside `postgres`/`bigquery` during the same + // `UnmarshalExact` decode every `SUPABASE_*` override goes through — + // a malformed `SUPABASE_ANALYTICS_BACKEND` fails + // config loading outright, same mechanism as the port/bool overrides below. + it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { + stubEnv("SUPABASE_ANALYTICS_BACKEND", "mysql"); + const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + LegacyConfigValidateError, + ); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', + ); + }); }); - it("accepts env-provided GCP fields overriding empty config.toml values", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "proj"; - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "123"; - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "gcp.json"; - const config = baseConfig({ analytics: { enabled: true, backend: "bigquery" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + describe("experimental.* (experimental.validate())", () => { + // `(e *experimental) validate()`, + // called right after the analytics/bigquery block and right before + // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. + // + // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks + // moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` + // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of + // deriving them from a raw `document`) — only this document-THREADING-specific case stays + // here, since it exercises this function's own "no document provided" fallback rather than + // a check `legacyValidateResolvedConfig` itself owns. + it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { + // No `document` (5th param) at all — e.g. a caller that hasn't threaded + // `LoadedProjectConfig.document` through yet. The presence-only check + // can't run without it, so it's skipped rather than guessed at; this + // also covers every pre-existing call site/test in this file that + // doesn't pass a 5th argument. + const config = baseConfig({ experimental: { webhooks: {} } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - // `LogflareBackend.UnmarshalText` hard-rejects any - // `analytics.backend` value outside `postgres`/`bigquery` during the same - // `UnmarshalExact` decode every `SUPABASE_*` override goes through — - // a malformed `SUPABASE_ANALYTICS_BACKEND` fails - // config loading outright, same mechanism as the port/bool overrides below. - it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { - process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; - const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidAnalyticsBackendEnvOverrideError, - ); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', - ); - }); - }); + // `experimental.webhooks.enabled`/`experimental.pgdelta.format_options` are Viper-bound like + // any other leaf field once `[experimental]` is present (`ExperimentalBindStruct`/ + // `AutomaticEnv`) — same SUPABASE_*-env-override MECHANICS split as + // `auth.captcha`/`auth.passkey` above: the required-field/JSON-shape checks themselves live in + // `legacy-config-validate.unit.test.ts`, only the env-override wiring is exercised here. + afterEach(() => { + stubEnv("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", undefined); + stubEnv("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", undefined); + }); - describe("experimental.* (experimental.validate())", () => { - // `(e *experimental) validate()`, - // called right after the analytics/bigquery block and right before - // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. - // - // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks - // moved to `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` - // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of - // deriving them from a raw `document`) — only this document-THREADING-specific case stays - // here, since it exercises this function's own "no document provided" fallback rather than - // a check `legacyValidateResolvedConfig` itself owns. - it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { - // No `document` (5th param) at all — e.g. a caller that hasn't threaded - // `LoadedProjectConfig.document` through yet. The presence-only check - // can't run without it, so it's skipped rather than guessed at; this - // also covers every pre-existing call site/test in this file that - // doesn't pass a 5th argument. - const config = baseConfig({ experimental: { webhooks: {} } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("enables webhooks purely via SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when the section omits enabled", () => { + stubEnv("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", "true"); + const config = baseConfig({ experimental: { webhooks: {} } }); + const document = { experimental: { webhooks: {} } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); - // `experimental.webhooks.enabled`/`experimental.pgdelta.format_options` are Viper-bound like - // any other leaf field once `[experimental]` is present (`ExperimentalBindStruct`/ - // `AutomaticEnv`) — same SUPABASE_*-env-override MECHANICS split as - // `auth.captcha`/`auth.passkey` above: the required-field/JSON-shape checks themselves live in - // `legacy-config-validate.unit.test.ts`, only the env-override wiring is exercised here. - afterEach(() => { - delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; - delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"]; - }); + it("rejects a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED override on an already-enabled section", () => { + stubEnv("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", "notabool"); + const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); + const document = { experimental: { webhooks: { enabled: true } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow( + 'Invalid config for experimental.webhooks.enabled: cannot parse "notabool" as a bool', + ); + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow( + 'Invalid config for experimental.webhooks.enabled: cannot parse "notabool" as a bool', + ); + }); - it("enables webhooks purely via SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when the section omits enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "true"; - const config = baseConfig({ experimental: { webhooks: {} } }); - const document = { experimental: { webhooks: {} } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); + it("rejects an invalid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { + stubEnv("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", "{not valid json"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config for experimental.pgdelta.format_options: must be valid JSON", + ); + }); - it("rejects a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED override on an already-enabled section", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "notabool"; - const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); - const document = { experimental: { webhooks: { enabled: true } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(LegacyInvalidBoolEnvOverrideError); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - 'Invalid config for experimental.webhooks.enabled: cannot parse "notabool" as a bool', - ); - }); + it("accepts a valid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { + stubEnv("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", '{"keywordCase":"upper"}'); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("rejects an invalid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config for experimental.pgdelta.format_options: must be valid JSON", - ); + it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { + // Same `experimental.webhooks.enabled` bug class, just for the OTHER Viper-bound + // `[experimental]` leaf this resolver derives: `experimental.pgdelta.format_options` is + // ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS`, so a matched `[remotes.<ref>]` block's own valid + // value must win over a malformed ambient env override, matching `mergeRemoteConfig` + // (`v.Set` above `AutomaticEnv`). + stubEnv("SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS", "{not valid json"); + const config = baseConfig({ + experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, + }); + expect(() => + resolveLocalConfigValues( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["experimental.pgdelta.format_options"]), + ), + ).not.toThrow(); + }); }); - it("accepts a valid JSON SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS override", () => { - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = '{"keywordCase":"upper"}'; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + describe("SUPABASE_API_TLS_ENABLED env override", () => { + // Go applies the Viper-bound `api.tls.enabled` override + // BEFORE deriving the default `api.external_url` scheme, + // so an ambient/dotenv override flips http/https even when config.toml says + // otherwise. + afterEach(() => { + stubEnv("SUPABASE_API_TLS_ENABLED", undefined); + }); - it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { - // Same `experimental.webhooks.enabled` bug class, just for the OTHER Viper-bound - // `[experimental]` leaf this resolver derives: `experimental.pgdelta.format_options` is - // ALSO in `LEGACY_ENV_OVERRIDABLE_KEYS`, so a matched `[remotes.<ref>]` block's own valid - // value must win over a malformed ambient env override, matching `mergeRemoteConfig` - // (`v.Set` above `AutomaticEnv`). - process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; - const config = baseConfig({ - experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, + it("overrides api.tls.enabled from false to true", () => { + stubEnv("SUPABASE_API_TLS_ENABLED", "true"); + const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); }); - expect(() => - legacyResolveLocalConfigValues( - config, - "127.0.0.1", - WORKDIR, - undefined, - undefined, - new Set(["experimental.pgdelta.format_options"]), - ), - ).not.toThrow(); - }); - }); - describe("SUPABASE_API_TLS_ENABLED env override", () => { - // Go applies the Viper-bound `api.tls.enabled` override - // BEFORE deriving the default `api.external_url` scheme, - // so an ambient/dotenv override flips http/https even when config.toml says - // otherwise. - afterEach(() => { - delete process.env["SUPABASE_API_TLS_ENABLED"]; - }); + it("overrides api.tls.enabled from true to false", () => { + stubEnv("SUPABASE_API_TLS_ENABLED", "false"); + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://127.0.0.1:54321"); + }); - it("overrides api.tls.enabled from false to true", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ api: { tls: { enabled: false }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); + it("does not override api.tls.enabled once api.external_url is set", () => { + stubEnv("SUPABASE_API_TLS_ENABLED", "true"); + const config = baseConfig({ api: { external_url: "http://config.example" } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("http://config.example"); + }); - it("overrides api.tls.enabled from true to false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "false"; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://127.0.0.1:54321"); - }); + it("rejects a malformed override instead of falling back to the configured value", () => { + stubEnv("SUPABASE_API_TLS_ENABLED", "not-a-bool"); + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for api.tls.enabled: cannot parse "not-a-bool" as a bool', + ); + }); - it("does not override api.tls.enabled once api.external_url is set", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ api: { external_url: "http://config.example" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("http://config.example"); + it("treats an empty override as unset, matching Viper's default", () => { + stubEnv("SUPABASE_API_TLS_ENABLED", ""); + const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.apiUrl).toBe("https://127.0.0.1:54321"); + }); }); - it("rejects a malformed override instead of falling back to the configured value", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "not-a-bool"; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); - }); + describe("legacyEnvOverrideRealtimeIpVersion", () => { + afterEach(() => { + stubEnv("SUPABASE_REALTIME_IP_VERSION", undefined); + }); - it("treats an empty override as unset, matching Viper's default", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = ""; - const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.apiUrl).toBe("https://127.0.0.1:54321"); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideRealtimeIpVersion("IPv4", testProjectEnvValues)).toBe("IPv4"); + }); - describe("legacyEnvOverrideRealtimeIpVersion", () => { - afterEach(() => { - delete process.env["SUPABASE_REALTIME_IP_VERSION"]; - }); + it("overrides IPv4 to IPv6 via the env var", () => { + stubEnv("SUPABASE_REALTIME_IP_VERSION", "IPv6"); + expect(legacyEnvOverrideRealtimeIpVersion("IPv4", testProjectEnvValues)).toBe("IPv6"); + }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv4"); + // `AddressFamily.UnmarshalText` hard-rejects + // any value outside `{IPv4, IPv6}` during the same `UnmarshalExact` decode every + // `SUPABASE_*` override goes through, same mechanism as the analytics backend override. + it("rejects an invalid override instead of falling back to the configured value", () => { + stubEnv("SUPABASE_REALTIME_IP_VERSION", "IPv5"); + expect(() => legacyEnvOverrideRealtimeIpVersion("IPv4", testProjectEnvValues)).toThrow( + LegacyInvalidRealtimeIpVersionEnvOverrideError, + ); + expect(() => legacyEnvOverrideRealtimeIpVersion("IPv4", testProjectEnvValues)).toThrow( + 'Invalid config for realtime.ip_version: cannot parse "IPv5" as one of "IPv4", "IPv6"', + ); + }); }); - it("overrides IPv4 to IPv6 via the env var", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv6"; - expect(legacyEnvOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv6"); - }); + describe("legacyEnvOverrideRealtimeMaxHeaderLength", () => { + afterEach(() => { + stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", undefined); + }); - // `AddressFamily.UnmarshalText` hard-rejects - // any value outside `{IPv4, IPv6}` during the same `UnmarshalExact` decode every - // `SUPABASE_*` override goes through, same mechanism as the analytics backend override. - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; - expect(() => legacyEnvOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - LegacyInvalidRealtimeIpVersionEnvOverrideError, - ); - expect(() => legacyEnvOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - 'Invalid config for realtime.ip_version: cannot parse "IPv5" as one of "IPv4", "IPv6"', - ); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideRealtimeMaxHeaderLength(4096, testProjectEnvValues)).toBe(4096); + }); - describe("legacyEnvOverrideRealtimeMaxHeaderLength", () => { - afterEach(() => { - delete process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"]; - }); + it("overrides the configured value via the env var", () => { + stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", "8192"); + expect(legacyEnvOverrideRealtimeMaxHeaderLength(4096, testProjectEnvValues)).toBe(8192); + }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(4096); - }); + it("also honors a projectEnvValues (dotenv) value", () => { + expect( + legacyEnvOverrideRealtimeMaxHeaderLength(4096, { + SUPABASE_REALTIME_MAX_HEADER_LENGTH: "16384", + }), + ).toBe(16384); + }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "8192"; - expect(legacyEnvOverrideRealtimeMaxHeaderLength(4096, undefined)).toBe(8192); - }); + // `uint` is 64 bits wide on every platform this CLI ships for, decoded via + // mapstructure's `decodeUint` (`strconv.ParseUint(str, 0, 64)`) under Viper's + // `WeaklyTypedInput: true`. A value one past `2^64-1` genuinely fails that parse in Go, so + // it must be rejected here too instead of silently losing precision through `Number(value)`. + it("rejects an override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { + stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", "18446744073709551616"); + expect(() => legacyEnvOverrideRealtimeMaxHeaderLength(4096, testProjectEnvValues)).toThrow( + "Failed reading config: Invalid realtime.max_header_length: 18446744073709551616.", + ); + }); - it("also honors a projectEnvValues (dotenv) value", () => { - expect( - legacyEnvOverrideRealtimeMaxHeaderLength(4096, { - SUPABASE_REALTIME_MAX_HEADER_LENGTH: "16384", - }), - ).toBe(16384); - }); + // Guards against an overcorrected fix that used an imprecise `Number`-based bound and + // rejected values Go itself still accepts. + it("accepts an override of exactly the uint64 max (2^64-1)", () => { + stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", "18446744073709551615"); + expect(() => + legacyEnvOverrideRealtimeMaxHeaderLength(4096, testProjectEnvValues), + ).not.toThrow(); + }); - // `uint` is 64 bits wide on every platform this CLI ships for, decoded via - // mapstructure's `decodeUint` (`strconv.ParseUint(str, 0, 64)`) under Viper's - // `WeaklyTypedInput: true`. A value one past `2^64-1` genuinely fails that parse in Go, so - // it must be rejected here too instead of silently losing precision through `Number(value)`. - it("rejects an override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; - expect(() => legacyEnvOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( - "Failed reading config: Invalid realtime.max_header_length: 18446744073709551616.", - ); + // Guards against the base-0 grammar rewrite (`parseGoBaseZeroUint`) silently + // reintroducing precision loss or an unbounded parse for a non-decimal literal — + // `0x10000000000000000` is exactly 2^64, one past `LEGACY_UINT_MAX`, same as the + // decimal literal above, just routed through the hex branch instead of the plain + // decimal branch. + it("rejects a hex override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { + stubEnv("SUPABASE_REALTIME_MAX_HEADER_LENGTH", "0x10000000000000000"); + expect(() => legacyEnvOverrideRealtimeMaxHeaderLength(4096, testProjectEnvValues)).toThrow( + "Failed reading config: Invalid realtime.max_header_length: 0x10000000000000000.", + ); + }); }); - // Guards against an overcorrected fix that used an imprecise `Number`-based bound and - // rejected values Go itself still accepts. - it("accepts an override of exactly the uint64 max (2^64-1)", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551615"; - expect(() => legacyEnvOverrideRealtimeMaxHeaderLength(4096, undefined)).not.toThrow(); - }); + describe("legacyEnvOverrideApiMaxRows", () => { + afterEach(() => { + stubEnv("SUPABASE_API_MAX_ROWS", undefined); + }); - // Guards against the base-0 grammar rewrite (`parseGoBaseZeroUint`) silently - // reintroducing precision loss or an unbounded parse for a non-decimal literal — - // `0x10000000000000000` is exactly 2^64, one past `LEGACY_UINT_MAX`, same as the - // decimal literal above, just routed through the hex branch instead of the plain - // decimal branch. - it("rejects a hex override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; - expect(() => legacyEnvOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( - "Failed reading config: Invalid realtime.max_header_length: 0x10000000000000000.", - ); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideApiMaxRows(1000, testProjectEnvValues)).toBe(1000); + }); - describe("legacyEnvOverrideApiMaxRows", () => { - afterEach(() => { - delete process.env["SUPABASE_API_MAX_ROWS"]; + it("overrides the configured value via the env var", () => { + stubEnv("SUPABASE_API_MAX_ROWS", "500"); + expect(legacyEnvOverrideApiMaxRows(1000, testProjectEnvValues)).toBe(500); + }); }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideApiMaxRows(1000, undefined)).toBe(1000); - }); + // `legacyEnvOverrideMajorVersion` is exercised directly (rather than through the + // full `legacyResolveLocalConfigValues` pipeline, as the "db.major_version (required + // field in config)" describe block above does) so these assertions cover only + // `legacyEnvOverrideUint`'s base-0 grammar parsing, not `legacyValidateResolvedConfig`'s + // separate "is this a supported Postgres major version" switch — most of the octal/hex/ + // binary literals below don't correspond to a supported major version and would fail + // that unrelated downstream check even once correctly parsed. + describe("legacyEnvOverrideMajorVersion", () => { + afterEach(() => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", undefined); + }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_API_MAX_ROWS"] = "500"; - expect(legacyEnvOverrideApiMaxRows(1000, undefined)).toBe(500); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toBe(17); + }); - // `legacyEnvOverrideMajorVersion` is exercised directly (rather than through the - // full `legacyResolveLocalConfigValues` pipeline, as the "db.major_version (required - // field in config)" describe block above does) so these assertions cover only - // `legacyEnvOverrideUint`'s base-0 grammar parsing, not `legacyValidateResolvedConfig`'s - // separate "is this a supported Postgres major version" switch — most of the octal/hex/ - // binary literals below don't correspond to a supported major version and would fail - // that unrelated downstream check even once correctly parsed. - describe("legacyEnvOverrideMajorVersion", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_MAJOR_VERSION"]; - }); + // Go's `strconv.ParseUint(str, 0, 64)` treats a bare leading zero followed by more + // digits as octal, not decimal — `"010"` is `8`, not `10` — a silent value + // divergence the old `/^\d+$/`-plus-`Number()` parsing didn't reproduce. + it("resolves an octal leading-zero override to its octal value, not decimal", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "010"); + expect(legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toBe(8); + }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideMajorVersion(17, undefined)).toBe(17); - }); + it("resolves a 0x-prefixed override as hex", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "0x10"); + expect(legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toBe(16); + }); - // Go's `strconv.ParseUint(str, 0, 64)` treats a bare leading zero followed by more - // digits as octal, not decimal — `"010"` is `8`, not `10` — a silent value - // divergence the old `/^\d+$/`-plus-`Number()` parsing didn't reproduce. - it("resolves an octal leading-zero override to its octal value, not decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "010"; - expect(legacyEnvOverrideMajorVersion(17, undefined)).toBe(8); - }); + it("resolves a 0b-prefixed override as binary", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "0b101"); + expect(legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toBe(5); + }); - it("resolves a 0x-prefixed override as hex", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0x10"; - expect(legacyEnvOverrideMajorVersion(17, undefined)).toBe(16); - }); + it("still resolves a plain decimal override with no leading zero", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "15"); + expect(legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toBe(15); + }); - it("resolves a 0b-prefixed override as binary", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "0b101"; - expect(legacyEnvOverrideMajorVersion(17, undefined)).toBe(5); - }); + // Underscore digit separators are only legal in Go's base-0 mode (Go 1.13+). + it("permits an underscore digit separator between decimal digits", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "1_000"); + expect(legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toBe(1000); + }); - it("still resolves a plain decimal override with no leading zero", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "15"; - expect(legacyEnvOverrideMajorVersion(17, undefined)).toBe(15); - }); + // Go does NOT fall back to decimal when a bare-leading-zero literal contains an + // invalid octal digit — `"08"`/`"09"` are rejected outright, never read as 8/9. + it("rejects an invalid octal digit instead of silently falling back to decimal", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "08"); + expect(() => legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toThrow( + "Failed reading config: Invalid db.major_version: 08.", + ); + }); - // Underscore digit separators are only legal in Go's base-0 mode (Go 1.13+). - it("permits an underscore digit separator between decimal digits", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "1_000"; - expect(legacyEnvOverrideMajorVersion(17, undefined)).toBe(1000); + // `strconv.ParseUint` never accepts a leading sign, unlike `ParseInt`. + it("rejects a signed override", () => { + stubEnv("SUPABASE_DB_MAJOR_VERSION", "+5"); + expect(() => legacyEnvOverrideMajorVersion(17, testProjectEnvValues)).toThrow( + "Failed reading config: Invalid db.major_version: +5.", + ); + }); }); - // Go does NOT fall back to decimal when a bare-leading-zero literal contains an - // invalid octal digit — `"08"`/`"09"` are rejected outright, never read as 8/9. - it("rejects an invalid octal digit instead of silently falling back to decimal", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "08"; - expect(() => legacyEnvOverrideMajorVersion(17, undefined)).toThrow( - "Failed reading config: Invalid db.major_version: 08.", - ); - }); + describe("legacyEnvOverridePoolMode", () => { + afterEach(() => { + stubEnv("SUPABASE_DB_POOLER_POOL_MODE", undefined); + }); - // `strconv.ParseUint` never accepts a leading sign, unlike `ParseInt`. - it("rejects a signed override", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "+5"; - expect(() => legacyEnvOverrideMajorVersion(17, undefined)).toThrow( - "Failed reading config: Invalid db.major_version: +5.", - ); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverridePoolMode("transaction", testProjectEnvValues)).toBe("transaction"); + }); - describe("legacyEnvOverridePoolMode", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_POOL_MODE"]; - }); + it("overrides the configured value via the env var", () => { + stubEnv("SUPABASE_DB_POOLER_POOL_MODE", "session"); + expect(legacyEnvOverridePoolMode("transaction", testProjectEnvValues)).toBe("session"); + }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverridePoolMode("transaction", undefined)).toBe("transaction"); + // `PoolMode.UnmarshalText` hard-rejects any + // value outside `{transaction, session}`. + it("rejects an invalid override instead of falling back to the configured value", () => { + stubEnv("SUPABASE_DB_POOLER_POOL_MODE", "invalid"); + expect(() => legacyEnvOverridePoolMode("transaction", testProjectEnvValues)).toThrow( + LegacyInvalidPoolModeEnvOverrideError, + ); + expect(() => legacyEnvOverridePoolMode("transaction", testProjectEnvValues)).toThrow( + 'Invalid config for db.pooler.pool_mode: cannot parse "invalid" as one of "transaction", "session"', + ); + }); }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "session"; - expect(legacyEnvOverridePoolMode("transaction", undefined)).toBe("session"); - }); + describe("legacyEnvOverrideEdgeRuntimePolicy", () => { + afterEach(() => { + stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", undefined); + }); - // `PoolMode.UnmarshalText` hard-rejects any - // value outside `{transaction, session}`. - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "invalid"; - expect(() => legacyEnvOverridePoolMode("transaction", undefined)).toThrow( - LegacyInvalidPoolModeEnvOverrideError, - ); - expect(() => legacyEnvOverridePoolMode("transaction", undefined)).toThrow( - 'Invalid config for db.pooler.pool_mode: cannot parse "invalid" as one of "transaction", "session"', - ); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideEdgeRuntimePolicy("oneshot", testProjectEnvValues)).toBe("oneshot"); + }); - describe("legacyEnvOverrideEdgeRuntimePolicy", () => { - afterEach(() => { - delete process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; - }); + it("overrides the configured value via the env var", () => { + stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", "per_worker"); + expect(legacyEnvOverrideEdgeRuntimePolicy("oneshot", testProjectEnvValues)).toBe( + "per_worker", + ); + }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("oneshot"); + // `RequestPolicy.UnmarshalText` hard-rejects + // any value outside `{per_worker, oneshot}`. + it("rejects an invalid override instead of falling back to the configured value", () => { + stubEnv("SUPABASE_EDGE_RUNTIME_POLICY", "invalid"); + expect(() => legacyEnvOverrideEdgeRuntimePolicy("oneshot", testProjectEnvValues)).toThrow( + LegacyInvalidEdgeRuntimePolicyEnvOverrideError, + ); + expect(() => legacyEnvOverrideEdgeRuntimePolicy("oneshot", testProjectEnvValues)).toThrow( + 'Invalid config for edge_runtime.policy: cannot parse "invalid" as one of "per_worker", "oneshot"', + ); + }); }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "per_worker"; - expect(legacyEnvOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("per_worker"); - }); + describe("legacyEnvOverrideDefaultPoolSize", () => { + afterEach(() => { + stubEnv("SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", undefined); + }); - // `RequestPolicy.UnmarshalText` hard-rejects - // any value outside `{per_worker, oneshot}`. - it("rejects an invalid override instead of falling back to the configured value", () => { - process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "invalid"; - expect(() => legacyEnvOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - LegacyInvalidEdgeRuntimePolicyEnvOverrideError, - ); - expect(() => legacyEnvOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - 'Invalid config for edge_runtime.policy: cannot parse "invalid" as one of "per_worker", "oneshot"', - ); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideDefaultPoolSize(20, testProjectEnvValues)).toBe(20); + }); - describe("legacyEnvOverrideDefaultPoolSize", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"]; + it("overrides the configured value via the env var", () => { + stubEnv("SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", "40"); + expect(legacyEnvOverrideDefaultPoolSize(20, testProjectEnvValues)).toBe(40); + }); }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideDefaultPoolSize(20, undefined)).toBe(20); - }); + describe("legacyEnvOverrideMaxClientConn", () => { + afterEach(() => { + stubEnv("SUPABASE_DB_POOLER_MAX_CLIENT_CONN", undefined); + }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE"] = "40"; - expect(legacyEnvOverrideDefaultPoolSize(20, undefined)).toBe(40); - }); - }); + it("falls back to the configured value when unset", () => { + expect(legacyEnvOverrideMaxClientConn(100, testProjectEnvValues)).toBe(100); + }); - describe("legacyEnvOverrideMaxClientConn", () => { - afterEach(() => { - delete process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"]; + it("overrides the configured value via the env var", () => { + stubEnv("SUPABASE_DB_POOLER_MAX_CLIENT_CONN", "200"); + expect(legacyEnvOverrideMaxClientConn(100, testProjectEnvValues)).toBe(200); + }); }); - it("falls back to the configured value when unset", () => { - expect(legacyEnvOverrideMaxClientConn(100, undefined)).toBe(100); - }); + describe("legacyResolveAuthCaptcha", () => { + afterEach(() => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", undefined); + stubEnv("SUPABASE_AUTH_CAPTCHA_SECRET", undefined); + }); - it("overrides the configured value via the env var", () => { - process.env["SUPABASE_DB_POOLER_MAX_CLIENT_CONN"] = "200"; - expect(legacyEnvOverrideMaxClientConn(100, undefined)).toBe(200); - }); - }); + it("returns undefined when captcha is not configured", () => { + expect( + legacyResolveAuthCaptcha(undefined, undefined, testProjectEnvValues), + ).toBeUndefined(); + }); - describe("legacyResolveAuthCaptcha", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; - }); + it("overrides enabled/provider when the section is present in the document", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "true"); + stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", "turnstile"); + const authDocument = { captcha: { enabled: false, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + testProjectEnvValues, + ); + expect(resolved?.enabled).toBe(true); + expect(resolved?.provider).toBe("turnstile"); + }); - it("returns undefined when captcha is not configured", () => { - expect(legacyResolveAuthCaptcha(undefined, undefined, undefined)).toBeUndefined(); - }); + it("does not apply an env override when [auth.captcha] is absent from the document", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "true"); + const resolved = legacyResolveAuthCaptcha( + {}, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + testProjectEnvValues, + ); + expect(resolved?.enabled).toBe(false); + }); - it("overrides enabled/provider when the section is present in the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; - const authDocument = { captcha: { enabled: false, provider: "hcaptcha" } }; - const resolved = legacyResolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.enabled).toBe(true); - expect(resolved?.provider).toBe("turnstile"); - }); + it("decrypts an encrypted: captcha secret", () => { + stubEnv( + "DOTENV_PRIVATE_KEY", + "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb", + ); + const authDocument = { captcha: { enabled: true } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { + enabled: true, + provider: "hcaptcha", + secret: + "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/", + }, + testProjectEnvValues, + ); + expect(resolved?.secret).toBe("value"); + stubEnv("DOTENV_PRIVATE_KEY", undefined); + }); - it("does not apply an env override when [auth.captcha] is absent from the document", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const resolved = legacyResolveAuthCaptcha( - {}, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.enabled).toBe(false); - }); + it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G): same "throws before a value the caller + // needs is resolved" bug class as `studio.enabled`/`auth.enabled` above — this function's + // own ungated `legacyEnvOverrideBool` call would abort the whole + // `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a malformed + // override the remote block should have made irrelevant. + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "not-a-bool"); + const authDocument = { captcha: { enabled: false } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + testProjectEnvValues, + new Set(["auth.captcha.enabled"]), + ), + ).not.toThrow(); + }); - it("decrypts an encrypted: captcha secret", () => { - process.env["DOTENV_PRIVATE_KEY"] = - "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; - const authDocument = { captcha: { enabled: true } }; - const resolved = legacyResolveAuthCaptcha( - authDocument, - { - enabled: true, - provider: "hcaptcha", - secret: - "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/", - }, - undefined, - ); - expect(resolved?.secret).toBe("value"); - delete process.env["DOTENV_PRIVATE_KEY"]; - }); + it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "not-a-bool"); + const authDocument = { captcha: { enabled: false } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: false, provider: "hcaptcha", secret: "shh" }, + testProjectEnvValues, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); - it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G): same "throws before a value the caller - // needs is resolved" bug class as `studio.enabled`/`auth.enabled` above — this function's - // own ungated `legacyEnvOverrideBool` call would abort the whole - // `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a malformed - // override the remote block should have made irrelevant. - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; - const authDocument = { captcha: { enabled: false } }; - expect(() => - legacyResolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.enabled"]), - ), - ).not.toThrow(); - }); - - it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; - const authDocument = { captcha: { enabled: false } }; - expect(() => - legacyResolveAuthCaptcha( - authDocument, - { enabled: false, provider: "hcaptcha", secret: "shh" }, - undefined, - ), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); - - it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { - // Regression (review: PRRT_kwDOErm0O86XJ4HR) — same bug class as `auth.email.smtp.pass` - // (review: PRRT_kwDOErm0O86XJYol): this function's own ungated `legacyEnvOverride` call fed - // a malformed ambient override straight into `legacyDecryptAuthSecret`, which throws on an - // undecryptable `encrypted:...` value — aborting the whole `legacyResolveLocalConfigValues` - // caller (and the shadow it feeds) on an env value `v.Set` (override tier, above - // `AutomaticEnv`) never lets reach decryption once a remote block already set the secret. - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { captcha: { enabled: true } }; - const resolved = legacyResolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - new Set(["auth.captcha.secret"]), - ); - expect(resolved?.secret).toBe("remote-secret"); - }); - - it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { captcha: { enabled: true } }; - expect(() => - legacyResolveAuthCaptcha( + it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { + // Regression (review: PRRT_kwDOErm0O86XJ4HR) — same bug class as `auth.email.smtp.pass` + // (review: PRRT_kwDOErm0O86XJYol): this function's own ungated `legacyEnvOverride` call fed + // a malformed ambient override straight into `legacyDecryptAuthSecret`, which throws on an + // undecryptable `encrypted:...` value — aborting the whole `legacyResolveLocalConfigValues` + // caller (and the shadow it feeds) on an env value `v.Set` (override tier, above + // `AutomaticEnv`) never lets reach decryption once a remote block already set the secret. + stubEnv("SUPABASE_AUTH_CAPTCHA_SECRET", "encrypted:not-a-real-ciphertext"); + const authDocument = { captcha: { enabled: true } }; + const resolved = legacyResolveAuthCaptcha( authDocument, { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, - undefined, - ), - ).toThrow("failed to parse config: missing private key"); - }); - - it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYn): `provider` can't throw on its own - // (`legacyEnvOverride` is a plain string read), but an ungated override here still let a - // stale/unsupported ambient `SUPABASE_AUTH_CAPTCHA_PROVIDER` outrank a matched remote's own - // valid provider — `legacyValidateResolvedConfig`'s enum check downstream then aborts the - // whole `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a value Go's - // `v.Set` (override tier, above `AutomaticEnv`) never lets win. - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; - const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = legacyResolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - new Set(["auth.captcha.provider"]), - ); - expect(resolved?.provider).toBe("hcaptcha"); - }); - - it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "turnstile"; - const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; - const resolved = legacyResolveAuthCaptcha( - authDocument, - { enabled: true, provider: "hcaptcha", secret: "shh" }, - undefined, - ); - expect(resolved?.provider).toBe("turnstile"); - }); - }); - - describe("legacyResolveAuthEmail", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; - }); + testProjectEnvValues, + new Set(["auth.captcha.secret"]), + ); + expect(resolved?.secret).toBe("remote-secret"); + }); - // `emailTemplate.Subject` is `*string` — an explicit - // `subject = ""` in config.toml is a real, non-nil state, distinct from an absent key, that - // Go's mailer-env block honors by still emitting `GOTRUE_MAILER_SUBJECTS_*=` (empty). - it("keeps an explicit empty subject present in the raw document, not omitted", () => { - const config = baseConfig({ - auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, + it("still rejects a malformed SUPABASE_AUTH_CAPTCHA_SECRET when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_SECRET", "encrypted:not-a-real-ciphertext"); + const authDocument = { captcha: { enabled: true } }; + expect(() => + legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "remote-secret" }, + testProjectEnvValues, + ), + ).toThrow("failed to parse config: missing private key"); }); - const authDocument = { email: { template: { confirmation: { subject: "" } } } }; - const resolved = legacyResolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBe(""); - }); - it("omits the subject when the key is absent from the raw document", () => { - const config = baseConfig({ - auth: { email: { template: { confirmation: { content_path: "x" } } } }, + it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { + // Regression (review: PRRT_kwDOErm0O86XLAYn): `provider` can't throw on its own + // (`legacyEnvOverride` is a plain string read), but an ungated override here still let a + // stale/unsupported ambient `SUPABASE_AUTH_CAPTCHA_PROVIDER` outrank a matched remote's own + // valid provider — `legacyValidateResolvedConfig`'s enum check downstream then aborts the + // whole `legacyResolveLocalConfigValues` caller (and the shadow it feeds) on a value Go's + // `v.Set` (override tier, above `AutomaticEnv`) never lets win. + stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", "recaptcha"); + const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + testProjectEnvValues, + new Set(["auth.captcha.provider"]), + ); + expect(resolved?.provider).toBe("hcaptcha"); }); - const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = legacyResolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBeUndefined(); - }); - it("prefers an env-overridden subject over the raw document's presence, even when absent", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"] = "Overridden subject"; - const config = baseConfig({ - auth: { email: { template: { confirmation: { content_path: "x" } } } }, + it("still applies SUPABASE_AUTH_CAPTCHA_PROVIDER when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", "turnstile"); + const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; + const resolved = legacyResolveAuthCaptcha( + authDocument, + { enabled: true, provider: "hcaptcha", secret: "shh" }, + testProjectEnvValues, + ); + expect(resolved?.provider).toBe("turnstile"); }); - const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; - const resolved = legacyResolveAuthEmail(config.auth.email, authDocument, undefined); - expect(resolved.template["confirmation"]?.subject).toBe("Overridden subject"); }); - describe("max_frequency — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + describe("legacyResolveAuthEmail", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"]; + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT", undefined); }); - it("prefers a remote-set auth.email.max_frequency over a conflicting SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; - const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); + // `emailTemplate.Subject` is `*string` — an explicit + // `subject = ""` in config.toml is a real, non-nil state, distinct from an absent key, that + // Go's mailer-env block honors by still emitting `GOTRUE_MAILER_SUBJECTS_*=` (empty). + it("keeps an explicit empty subject present in the raw document, not omitted", () => { + const config = baseConfig({ + auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, + }); + const authDocument = { email: { template: { confirmation: { subject: "" } } } }; const resolved = legacyResolveAuthEmail( config.auth.email, - undefined, - undefined, - new Set(["auth.email.max_frequency"]), + authDocument, + testProjectEnvValues, ); - expect(resolved.max_frequency).toBe("1m"); + expect(resolved.template["confirmation"]?.subject).toBe(""); }); - it("still applies SUPABASE_AUTH_EMAIL_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_MAX_FREQUENCY"] = "5s"; - const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); - const resolved = legacyResolveAuthEmail(config.auth.email, undefined, undefined); - expect(resolved.max_frequency).toBe("5s"); + it("omits the subject when the key is absent from the raw document", () => { + const config = baseConfig({ + auth: { email: { template: { confirmation: { content_path: "x" } } } }, + }); + const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; + const resolved = legacyResolveAuthEmail( + config.auth.email, + authDocument, + testProjectEnvValues, + ); + expect(resolved.template["confirmation"]?.subject).toBeUndefined(); }); - }); - }); - - describe("legacyResolveAuthHooks", () => { - const baseHook = { enabled: false, uri: "", secrets: "" }; - const allHooks = { - mfa_verification_attempt: baseHook, - password_verification_attempt: baseHook, - custom_access_token: baseHook, - send_sms: baseHook, - send_email: baseHook, - before_user_created: baseHook, - }; - afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"]; - }); + it("prefers an env-overridden subject over the raw document's presence, even when absent", () => { + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT", "Overridden subject"); + const config = baseConfig({ + auth: { email: { template: { confirmation: { content_path: "x" } } } }, + }); + const authDocument = { email: { template: { confirmation: { content_path: "x" } } } }; + const resolved = legacyResolveAuthEmail( + config.auth.email, + authDocument, + testProjectEnvValues, + ); + expect(resolved.template["confirmation"]?.subject).toBe("Overridden subject"); + }); - it("leaves every hook disabled when nothing is configured or overridden", () => { - const resolved = legacyResolveAuthHooks(undefined, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(false); - expect(resolved.mfaVerificationAttempt.enabled).toBe(false); - }); + describe("max_frequency — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + stubEnv("SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", undefined); + }); - it("overrides enabled/uri when the hook's section is present in the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://example.com/hook"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - const resolved = legacyResolveAuthHooks(authDocument, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(true); - expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - // Unrelated hooks stay untouched. - expect(resolved.mfaVerificationAttempt.enabled).toBe(false); - }); + it("prefers a remote-set auth.email.max_frequency over a conflicting SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", () => { + stubEnv("SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", "5s"); + const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); + const resolved = legacyResolveAuthEmail( + config.auth.email, + testProjectEnvValues, + undefined, + new Set(["auth.email.max_frequency"]), + ); + expect(resolved.max_frequency).toBe("1m"); + }); - it("does not apply an env override when the hook's section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "true"; - const resolved = legacyResolveAuthHooks({}, allHooks, undefined); - expect(resolved.customAccessToken.enabled).toBe(false); + it("still applies SUPABASE_AUTH_EMAIL_MAX_FREQUENCY when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", "5s"); + const config = baseConfig({ auth: { email: { max_frequency: "1m" } } }); + const resolved = legacyResolveAuthEmail( + config.auth.email, + undefined, + testProjectEnvValues, + ); + expect(resolved.max_frequency).toBe("5s"); + }); + }); }); - it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => - legacyResolveAuthHooks( - authDocument, - allHooks, - undefined, - new Set(["auth.hook.custom_access_token.enabled"]), - ), - ).not.toThrow(); - }); + describe("legacyResolveAuthHooks", () => { + const baseHook = { enabled: false, uri: "", secrets: "" }; + const allHooks = { + mfa_verification_attempt: baseHook, + password_verification_attempt: baseHook, + custom_access_token: baseHook, + send_sms: baseHook, + send_email: baseHook, + before_user_created: baseHook, + }; - it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; - const authDocument = { hook: { custom_access_token: { enabled: false } } }; - expect(() => legacyResolveAuthHooks(authDocument, allHooks, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); + afterEach(() => { + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", undefined); + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", undefined); + }); - it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { - // Regression (review: PRRT_kwDOErm0O86XGTq5) — `mergeRemoteConfig` flattens the whole - // matched block via `u.AllKeys()` and applies EVERY leaf with `v.Set`, - // not just `enabled`. Leaving `uri` ungated - // let a stale/malformed env var beat a remote's already-merged, valid `uri`. - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; - const hooksWithRemoteUri = { - ...allHooks, - custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = legacyResolveAuthHooks( - authDocument, - hooksWithRemoteUri, - undefined, - new Set(["auth.hook.custom_access_token.uri"]), - ); - expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - }); + it("leaves every hook disabled when nothing is configured or overridden", () => { + const resolved = legacyResolveAuthHooks(undefined, allHooks, testProjectEnvValues); + expect(resolved.customAccessToken.enabled).toBe(false); + expect(resolved.mfaVerificationAttempt.enabled).toBe(false); + }); - it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS"] = "env-secret"; - const hooksWithRemoteSecrets = { - ...allHooks, - custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = legacyResolveAuthHooks( - authDocument, - hooksWithRemoteSecrets, - undefined, - new Set(["auth.hook.custom_access_token.secrets"]), - ); - expect(resolved.customAccessToken.secrets).toBe("remote-secret"); - }); + it("overrides enabled/uri when the hook's section is present in the document", () => { + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", "true"); + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", "https://example.com/hook"); + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + const resolved = legacyResolveAuthHooks(authDocument, allHooks, testProjectEnvValues); + expect(resolved.customAccessToken.enabled).toBe(true); + expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); + // Unrelated hooks stay untouched. + expect(resolved.mfaVerificationAttempt.enabled).toBe(false); + }); - it("still applies the env override for uri when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "https://env.example.com/hook"; - const hooksWithLocalUri = { - ...allHooks, - custom_access_token: { enabled: true, uri: "https://local.example.com/hook", secrets: "" }, - }; - const authDocument = { hook: { custom_access_token: { enabled: true } } }; - const resolved = legacyResolveAuthHooks(authDocument, hooksWithLocalUri, undefined); - expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); - }); - }); + it("does not apply an env override when the hook's section is absent from the document", () => { + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", "true"); + const resolved = legacyResolveAuthHooks({}, allHooks, testProjectEnvValues); + expect(resolved.customAccessToken.enabled).toBe(false); + }); - describe("legacyResolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - }); + it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", "not-a-bool"); + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + expect(() => + legacyResolveAuthHooks( + authDocument, + allHooks, + testProjectEnvValues, + new Set(["auth.hook.custom_access_token.enabled"]), + ), + ).not.toThrow(); + }); - it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above: - // every `auth.mfa.*` leaf here is unconditionally resolved by - // `legacyResolveLocalConfigValues` (inside its `authEnabled` block), so an ungated call - // would abort that whole caller on a malformed override the remote block should have made - // irrelevant. - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const mfa = baseConfig().auth.mfa; - expect(() => - legacyResolveAuthMfa(mfa, undefined, new Set(["auth.mfa.totp.enroll_enabled"])), - ).not.toThrow(); - }); + it("still rejects a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED", "not-a-bool"); + const authDocument = { hook: { custom_access_token: { enabled: false } } }; + expect(() => legacyResolveAuthHooks(authDocument, allHooks, testProjectEnvValues)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); - it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const mfa = baseConfig().auth.mfa; - expect(() => legacyResolveAuthMfa(mfa, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); + it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { + // Regression (review: PRRT_kwDOErm0O86XGTq5) — `mergeRemoteConfig` flattens the whole + // matched block via `u.AllKeys()` and applies EVERY leaf with `v.Set`, + // not just `enabled`. Leaving `uri` ungated + // let a stale/malformed env var beat a remote's already-merged, valid `uri`. + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", "ftp://example.com"); + const hooksWithRemoteUri = { + ...allHooks, + custom_access_token: { enabled: true, uri: "https://example.com/hook", secrets: "" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithRemoteUri, + testProjectEnvValues, + new Set(["auth.hook.custom_access_token.uri"]), + ); + expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); + }); - it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, - }; - const resolved = legacyResolveAuthMfa(mfa, undefined, new Set(["auth.mfa.phone.template"])); - expect(resolved.phone.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; - }); + it("prefers a remote-set auth.hook.custom_access_token.secrets over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", () => { + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS", "env-secret"); + const hooksWithRemoteSecrets = { + ...allHooks, + custom_access_token: { enabled: true, uri: "", secrets: "remote-secret" }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithRemoteSecrets, + testProjectEnvValues, + new Set(["auth.hook.custom_access_token.secrets"]), + ); + expect(resolved.customAccessToken.secrets).toBe("remote-secret"); + }); - it("still applies SUPABASE_AUTH_MFA_PHONE_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"] = "env template"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, - }; - const resolved = legacyResolveAuthMfa(mfa, undefined); - expect(resolved.phone.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_TEMPLATE"]; + it("still applies the env override for uri when no remote block matched that leaf", () => { + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", "https://env.example.com/hook"); + const hooksWithLocalUri = { + ...allHooks, + custom_access_token: { + enabled: true, + uri: "https://local.example.com/hook", + secrets: "", + }, + }; + const authDocument = { hook: { custom_access_token: { enabled: true } } }; + const resolved = legacyResolveAuthHooks( + authDocument, + hooksWithLocalUri, + testProjectEnvValues, + ); + expect(resolved.customAccessToken.uri).toBe("https://env.example.com/hook"); + }); }); - it("prefers a remote-set auth.mfa.phone.max_frequency over a conflicting SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, - }; - const resolved = legacyResolveAuthMfa( - mfa, - undefined, - new Set(["auth.mfa.phone.max_frequency"]), - ); - expect(resolved.phone.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; - }); + describe("legacyResolveAuthMfa — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", undefined); + }); - it("still applies SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"] = "5s"; - const mfa = { - ...baseConfig().auth.mfa, - phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, - }; - const resolved = legacyResolveAuthMfa(mfa, undefined); - expect(resolved.phone.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY"]; - }); - }); + it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above: + // every `auth.mfa.*` leaf here is unconditionally resolved by + // `legacyResolveLocalConfigValues` (inside its `authEnabled` block), so an ungated call + // would abort that whole caller on a malformed override the remote block should have made + // irrelevant. + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "not-a-bool"); + const mfa = baseConfig().auth.mfa; + expect(() => + legacyResolveAuthMfa( + mfa, + testProjectEnvValues, + new Set(["auth.mfa.totp.enroll_enabled"]), + ), + ).not.toThrow(); + }); - describe("legacyResolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - }); + it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "not-a-bool"); + const mfa = baseConfig().auth.mfa; + expect(() => legacyResolveAuthMfa(mfa, testProjectEnvValues)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); - it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; - const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => - legacyResolveAuthEmailSmtp(authDocument, undefined, new Set(["auth.email.smtp.enabled"])), - ).not.toThrow(); - }); + it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { + stubEnv("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", "env template"); + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, + }; + const resolved = legacyResolveAuthMfa( + mfa, + testProjectEnvValues, + new Set(["auth.mfa.phone.template"]), + ); + expect(resolved.phone.template).toBe("remote template"); + stubEnv("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", undefined); + }); - it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; - const authDocument = { email: { smtp: { enabled: true } } }; - expect(() => legacyResolveAuthEmailSmtp(authDocument, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); - }); + it("still applies SUPABASE_AUTH_MFA_PHONE_TEMPLATE when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", "env template"); + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, template: "remote template" }, + }; + const resolved = legacyResolveAuthMfa(mfa, testProjectEnvValues); + expect(resolved.phone.template).toBe("env template"); + stubEnv("SUPABASE_AUTH_MFA_PHONE_TEMPLATE", undefined); + }); - it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { - // Regression (review: PRRT_kwDOErm0O86XJYol) — same bug class as `.enabled`/`.port` - // above, just for this Secret-typed leaf: an ungated env override reached - // `legacyDecryptAuthSecret` and threw before the remote's own valid `pass` was used. - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - const resolved = legacyResolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.pass"]), - ); - expect(resolved?.pass).toBe("remote-pass"); - }); + it("prefers a remote-set auth.mfa.phone.max_frequency over a conflicting SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", () => { + stubEnv("SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", "5s"); + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, + }; + const resolved = legacyResolveAuthMfa( + mfa, + testProjectEnvValues, + new Set(["auth.mfa.phone.max_frequency"]), + ); + expect(resolved.phone.max_frequency).toBe("1m"); + stubEnv("SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", undefined); + }); - it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; - const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; - expect(() => legacyResolveAuthEmailSmtp(authDocument, undefined)).toThrow( - "failed to parse config: missing private key", - ); + it("still applies SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", "5s"); + const mfa = { + ...baseConfig().auth.mfa, + phone: { ...baseConfig().auth.mfa.phone, max_frequency: "1m" }, + }; + const resolved = legacyResolveAuthMfa(mfa, testProjectEnvValues); + expect(resolved.phone.max_frequency).toBe("5s"); + stubEnv("SUPABASE_AUTH_MFA_PHONE_MAX_FREQUENCY", undefined); + }); }); - it("prefers a remote-set auth.email.smtp.host over a conflicting SUPABASE_AUTH_EMAIL_SMTP_HOST", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; - const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = legacyResolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.host"]), - ); - expect(resolved?.host).toBe("smtp.remote.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - }); + describe("legacyResolveAuthEmailSmtp — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + afterEach(() => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PASS", undefined); + }); - it("still applies SUPABASE_AUTH_EMAIL_SMTP_HOST when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.env.example.com"; - const authDocument = { email: { smtp: { enabled: true, host: "smtp.remote.example.com" } } }; - const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.host).toBe("smtp.env.example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - }); + it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { + // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ENABLED", "not-a-bool"); + const authDocument = { email: { smtp: { enabled: true } } }; + expect(() => + legacyResolveAuthEmailSmtp( + authDocument, + testProjectEnvValues, + new Set(["auth.email.smtp.enabled"]), + ), + ).not.toThrow(); + }); - it("prefers a remote-set auth.email.smtp.user over a conflicting SUPABASE_AUTH_EMAIL_SMTP_USER", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; - const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = legacyResolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.user"]), - ); - expect(resolved?.user).toBe("remote-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - }); + it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ENABLED", "not-a-bool"); + const authDocument = { email: { smtp: { enabled: true } } }; + expect(() => legacyResolveAuthEmailSmtp(authDocument, testProjectEnvValues)).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); - it("still applies SUPABASE_AUTH_EMAIL_SMTP_USER when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "env-user"; - const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; - const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.user).toBe("env-user"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - }); + it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { + // Regression (review: PRRT_kwDOErm0O86XJYol) — same bug class as `.enabled`/`.port` + // above, just for this Secret-typed leaf: an ungated env override reached + // `legacyDecryptAuthSecret` and threw before the remote's own valid `pass` was used. + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PASS", "encrypted:not-a-real-ciphertext"); + const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + testProjectEnvValues, + new Set(["auth.email.smtp.pass"]), + ); + expect(resolved?.pass).toBe("remote-pass"); + }); - it("prefers a remote-set auth.email.smtp.admin_email over a conflicting SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; - const authDocument = { - email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, - }; - const resolved = legacyResolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.admin_email"]), - ); - expect(resolved?.adminEmail).toBe("remote@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); + it("still rejects a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PASS", "encrypted:not-a-real-ciphertext"); + const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; + expect(() => legacyResolveAuthEmailSmtp(authDocument, testProjectEnvValues)).toThrow( + "failed to parse config: missing private key", + ); + }); - it("still applies SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "env@example.com"; - const authDocument = { - email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, - }; - const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.adminEmail).toBe("env@example.com"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); + it("prefers a remote-set auth.email.smtp.host over a conflicting SUPABASE_AUTH_EMAIL_SMTP_HOST", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", "smtp.env.example.com"); + const authDocument = { + email: { smtp: { enabled: true, host: "smtp.remote.example.com" } }, + }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + testProjectEnvValues, + new Set(["auth.email.smtp.host"]), + ); + expect(resolved?.host).toBe("smtp.remote.example.com"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", undefined); + }); - it("prefers a remote-set auth.email.smtp.sender_name over a conflicting SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; - const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = legacyResolveAuthEmailSmtp( - authDocument, - undefined, - new Set(["auth.email.smtp.sender_name"]), - ); - expect(resolved?.senderName).toBe("Remote Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; - }); + it("still applies SUPABASE_AUTH_EMAIL_SMTP_HOST when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", "smtp.env.example.com"); + const authDocument = { + email: { smtp: { enabled: true, host: "smtp.remote.example.com" } }, + }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, testProjectEnvValues); + expect(resolved?.host).toBe("smtp.env.example.com"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", undefined); + }); - it("still applies SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"] = "Env Sender"; - const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; - const resolved = legacyResolveAuthEmailSmtp(authDocument, undefined); - expect(resolved?.senderName).toBe("Env Sender"); - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME"]; - }); - }); + it("prefers a remote-set auth.email.smtp.user over a conflicting SUPABASE_AUTH_EMAIL_SMTP_USER", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", "env-user"); + const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + testProjectEnvValues, + new Set(["auth.email.smtp.user"]), + ); + expect(resolved?.user).toBe("remote-user"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", undefined); + }); - describe("legacyResolveAuthExternalProviders", () => { - it("coerces an env(...)-resolved boolean string for an unmodeled/custom provider", () => { - const authDocument = { - external: { - my_custom: { - enabled: "true", - client_id: "custom-client-id", - skip_nonce_check: "false", - email_optional: "TRUE", - }, - }, - }; - const resolved = legacyResolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(true); - expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); - expect(resolved["my_custom"]?.emailOptional).toBe(true); - }); + it("still applies SUPABASE_AUTH_EMAIL_SMTP_USER when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", "env-user"); + const authDocument = { email: { smtp: { enabled: true, user: "remote-user" } } }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, testProjectEnvValues); + expect(resolved?.user).toBe("env-user"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", undefined); + }); - it("throws on an unparsable custom-provider boolean string instead of silently disabling it", () => { - const authDocument = { - external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, - }; - expect(() => - legacyResolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); + it("prefers a remote-set auth.email.smtp.admin_email over a conflicting SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", "env@example.com"); + const authDocument = { + email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, + }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + testProjectEnvValues, + new Set(["auth.email.smtp.admin_email"]), + ); + expect(resolved?.adminEmail).toBe("remote@example.com"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", undefined); + }); - it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { - const authDocument = { - external: { my_custom: { client_id: "custom-client-id" } }, - }; - const resolved = legacyResolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(false); - }); + it("still applies SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", "env@example.com"); + const authDocument = { + email: { smtp: { enabled: true, admin_email: "remote@example.com" } }, + }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, testProjectEnvValues); + expect(resolved?.adminEmail).toBe("env@example.com"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", undefined); + }); - it("weakly coerces a raw numeric custom-provider boolean by truthiness, matching Go's WeaklyTypedInput decode", () => { - const authDocument = { - external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, - }; - const resolved = legacyResolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - undefined, - ); - expect(resolved["my_custom"]?.enabled).toBe(true); - }); + it("prefers a remote-set auth.email.smtp.sender_name over a conflicting SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", "Env Sender"); + const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; + const resolved = legacyResolveAuthEmailSmtp( + authDocument, + testProjectEnvValues, + new Set(["auth.email.smtp.sender_name"]), + ); + expect(resolved?.senderName).toBe("Remote Sender"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", undefined); + }); - it("throws on a raw array/table custom-provider boolean instead of silently disabling it", () => { - const authDocument = { - external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, - }; - expect(() => - legacyResolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('cannot parse "1,2" as a bool'); + it("still applies SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME when no remote block matched", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", "Env Sender"); + const authDocument = { email: { smtp: { enabled: true, sender_name: "Remote Sender" } } }; + const resolved = legacyResolveAuthEmailSmtp(authDocument, testProjectEnvValues); + expect(resolved?.senderName).toBe("Env Sender"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_SENDER_NAME", undefined); + }); }); - it("resolves apple purely from env overrides even with no config.toml [auth.external] section at all, matching Go's ejected default template", () => { - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_APPLE_ENABLED: "true", - SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "apple-client-id", - SUPABASE_AUTH_EXTERNAL_APPLE_SECRET: "apple-secret", - SUPABASE_AUTH_EXTERNAL_APPLE_URL: "https://appleid.apple.com", - }; - const resolved = legacyResolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, - ); - expect(resolved["apple"]).toEqual({ - enabled: true, - clientId: "apple-client-id", - secret: "apple-secret", - url: "https://appleid.apple.com", - redirectUri: "", - skipNonceCheck: false, - emailOptional: false, + describe("legacyResolveAuthExternalProviders", () => { + it("coerces an env(...)-resolved boolean string for an unmodeled/custom provider", () => { + const authDocument = { + external: { + my_custom: { + enabled: "true", + client_id: "custom-client-id", + skip_nonce_check: "false", + email_optional: "TRUE", + }, + }, + }; + const resolved = legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + testProjectEnvValues, + ); + expect(resolved["my_custom"]?.enabled).toBe(true); + expect(resolved["my_custom"]?.skipNonceCheck).toBe(false); + expect(resolved["my_custom"]?.emailOptional).toBe(true); }); - }); - it("does not synthesize any other provider purely from an env override with no TOML table, only apple gets Go's default-template exception", () => { - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_GOOGLE_ENABLED: "true", - SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID: "google-client-id", - }; - const resolved = legacyResolveAuthExternalProviders( - undefined, - baseConfig().auth.external, - projectEnvValues, - ); - expect(resolved["google"]).toBeUndefined(); - // apple is still unconditionally present (Go's default template), but unaffected by - // the unrelated google env vars above. - expect(resolved["apple"]?.enabled).toBe(false); - }); - }); + it("throws on an unparsable custom-provider boolean string instead of silently disabling it", () => { + const authDocument = { + external: { my_custom: { enabled: "not-a-bool", client_id: "custom-client-id" } }, + }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + testProjectEnvValues, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); - describe("legacyResolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // Regression (review: PRRT_kwDOErm0O86XKYiF): this resolver had no `remoteOverrideKeys` - // parameter at all, so a matched `[remotes.<ref>]` block's own valid `auth.external.<name>.*` - // value could always lose to a conflicting/malformed ambient `SUPABASE_AUTH_EXTERNAL_<NAME>_*` - // override — `secret`/`enabled`/`skip_nonce_check`/`email_optional` can additionally THROW on - // a malformed override, aborting the whole `legacyResolveLocalConfigValues` caller (and the - // shadow it feeds). - it("prefers a remote-set auth.external.<name>.secret over a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_SECRET", () => { - const authDocument = { - external: { my_custom: { enabled: true, secret: "remote-secret" } }, - }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - const resolved = legacyResolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.secret"]), - ); - expect(resolved["my_custom"]?.secret).toBe("remote-secret"); - }); + it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { + const authDocument = { + external: { my_custom: { client_id: "custom-client-id" } }, + }; + const resolved = legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + testProjectEnvValues, + ); + expect(resolved["my_custom"]?.enabled).toBe(false); + }); - it("still rejects a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_SECRET when no remote block matched", () => { - const authDocument = { - external: { my_custom: { enabled: true, secret: "remote-secret" } }, - }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; - expect(() => - legacyResolveAuthExternalProviders( + it("weakly coerces a raw numeric custom-provider boolean by truthiness, matching Go's WeaklyTypedInput decode", () => { + const authDocument = { + external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, + }; + const resolved = legacyResolveAuthExternalProviders( authDocument, baseConfig().auth.external, + testProjectEnvValues, + ); + expect(resolved["my_custom"]?.enabled).toBe(true); + }); + + it("throws on a raw array/table custom-provider boolean instead of silently disabling it", () => { + const authDocument = { + external: { my_custom: { enabled: [1, 2], client_id: "custom-client-id" } }, + }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + testProjectEnvValues, + ), + ).toThrow('cannot parse "1,2" as a bool'); + }); + + it("resolves apple purely from env overrides even with no config.toml [auth.external] section at all, matching Go's ejected default template", () => { + const projectEnvValues = { + SUPABASE_AUTH_EXTERNAL_APPLE_ENABLED: "true", + SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "apple-client-id", + SUPABASE_AUTH_EXTERNAL_APPLE_SECRET: "apple-secret", + SUPABASE_AUTH_EXTERNAL_APPLE_URL: "https://appleid.apple.com", + }; + const resolved = legacyResolveAuthExternalProviders( + undefined, + baseConfig().auth.external, projectEnvValues, - ), - ).toThrow("failed to parse config: missing private key"); + ); + expect(resolved["apple"]).toEqual({ + enabled: true, + clientId: "apple-client-id", + secret: "apple-secret", + url: "https://appleid.apple.com", + redirectUri: "", + skipNonceCheck: false, + emailOptional: false, + }); + }); + + it("does not synthesize any other provider purely from an env override with no TOML table, only apple gets Go's default-template exception", () => { + const projectEnvValues = { + SUPABASE_AUTH_EXTERNAL_GOOGLE_ENABLED: "true", + SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID: "google-client-id", + }; + const resolved = legacyResolveAuthExternalProviders( + undefined, + baseConfig().auth.external, + projectEnvValues, + ); + expect(resolved["google"]).toBeUndefined(); + // apple is still unconditionally present (Go's default template), but unaffected by + // the unrelated google env vars above. + expect(resolved["apple"]?.enabled).toBe(false); + }); }); - it("prefers a remote-set auth.external.<name>.enabled over a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_ENABLED", () => { - const authDocument = { external: { my_custom: { enabled: true } } }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; - expect(() => - legacyResolveAuthExternalProviders( + describe("legacyResolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { + // Regression (review: PRRT_kwDOErm0O86XKYiF): this resolver had no `remoteOverrideKeys` + // parameter at all, so a matched `[remotes.<ref>]` block's own valid `auth.external.<name>.*` + // value could always lose to a conflicting/malformed ambient `SUPABASE_AUTH_EXTERNAL_<NAME>_*` + // override — `secret`/`enabled`/`skip_nonce_check`/`email_optional` can additionally THROW on + // a malformed override, aborting the whole `legacyResolveLocalConfigValues` caller (and the + // shadow it feeds). + it("prefers a remote-set auth.external.<name>.secret over a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_SECRET", () => { + const authDocument = { + external: { my_custom: { enabled: true, secret: "remote-secret" } }, + }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; + const resolved = legacyResolveAuthExternalProviders( authDocument, baseConfig().auth.external, projectEnvValues, - new Set(["auth.external.my_custom.enabled"]), - ), - ).not.toThrow(); - }); + new Set(["auth.external.my_custom.secret"]), + ); + expect(resolved["my_custom"]?.secret).toBe("remote-secret"); + }); - it("still rejects a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_ENABLED when no remote block matched", () => { - const authDocument = { external: { my_custom: { enabled: true } } }; - const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; - expect(() => - legacyResolveAuthExternalProviders( + it("still rejects a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_SECRET when no remote block matched", () => { + const authDocument = { + external: { my_custom: { enabled: true, secret: "remote-secret" } }, + }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_SECRET: "encrypted:garbage" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + ), + ).toThrow("failed to parse config: missing private key"); + }); + + it("prefers a remote-set auth.external.<name>.enabled over a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_ENABLED", () => { + const authDocument = { external: { my_custom: { enabled: true } } }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + new Set(["auth.external.my_custom.enabled"]), + ), + ).not.toThrow(); + }); + + it("still rejects a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_ENABLED when no remote block matched", () => { + const authDocument = { external: { my_custom: { enabled: true } } }; + const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; + expect(() => + legacyResolveAuthExternalProviders( + authDocument, + baseConfig().auth.external, + projectEnvValues, + ), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); + + it("prefers a remote-set auth.external.<name>.client_id over a conflicting SUPABASE_AUTH_EXTERNAL_<NAME>_CLIENT_ID", () => { + const authDocument = { + external: { my_custom: { enabled: true, client_id: "remote-client-id" } }, + }; + const projectEnvValues = { + SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", + }; + const resolved = legacyResolveAuthExternalProviders( authDocument, baseConfig().auth.external, projectEnvValues, - ), - ).toThrow('cannot parse "not-a-bool" as a bool'); + new Set(["auth.external.my_custom.client_id"]), + ); + expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); + }); }); - it("prefers a remote-set auth.external.<name>.client_id over a conflicting SUPABASE_AUTH_EXTERNAL_<NAME>_CLIENT_ID", () => { - const authDocument = { - external: { my_custom: { enabled: true, client_id: "remote-client-id" } }, - }; - const projectEnvValues = { - SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_CLIENT_ID: "env-should-not-win", - }; - const resolved = legacyResolveAuthExternalProviders( - authDocument, - baseConfig().auth.external, - projectEnvValues, - new Set(["auth.external.my_custom.client_id"]), - ); - expect(resolved["my_custom"]?.clientId).toBe("remote-client-id"); - }); - }); + describe("legacyRawUnmodeledBool", () => { + it("returns false for an absent value, matching Go's zero-value bool default", () => { + expect(legacyRawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); + }); - describe("legacyRawUnmodeledBool", () => { - it("returns false for an absent value, matching Go's zero-value bool default", () => { - expect(legacyRawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); - }); + it("passes a real boolean through unchanged", () => { + expect(legacyRawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); + expect(legacyRawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); + }); - it("passes a real boolean through unchanged", () => { - expect(legacyRawUnmodeledBool(true, "auth.passkey.enabled")).toBe(true); - expect(legacyRawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); - }); + it("weakly coerces a raw number by truthiness, matching mapstructure's WeaklyTypedInput decodeBool", () => { + expect(legacyRawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); + expect(legacyRawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); + expect(legacyRawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); + }); - it("weakly coerces a raw number by truthiness, matching mapstructure's WeaklyTypedInput decodeBool", () => { - expect(legacyRawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); - expect(legacyRawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); - expect(legacyRawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); - }); + it("parses a valid boolean-ish string the way Go's strconv.ParseBool does", () => { + expect(legacyRawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); + expect(legacyRawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); + expect(legacyRawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); + }); - it("parses a valid boolean-ish string the way Go's strconv.ParseBool does", () => { - expect(legacyRawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); - expect(legacyRawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); - expect(legacyRawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); - }); + it("throws on an unparsable string instead of silently disabling it", () => { + expect(() => legacyRawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( + 'cannot parse "not-a-bool" as a bool', + ); + }); - it("throws on an unparsable string instead of silently disabling it", () => { - expect(() => legacyRawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( - 'cannot parse "not-a-bool" as a bool', - ); + it("throws on an array or table value — mapstructure's decodeBool errors on these unconditionally, never weakly coerced", () => { + expect(() => legacyRawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + expect(() => legacyRawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); }); - it("throws on an array or table value — mapstructure's decodeBool errors on these unconditionally, never weakly coerced", () => { - expect(() => legacyRawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); - expect(() => legacyRawUnmodeledBool({ nested: true }, "auth.passkey.enabled")).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); - }); - }); + describe("legacyResolveDbSettingsEnvOverrides", () => { + const ALL_OVERRIDE_NAMES = [ + "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", + "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", + "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", + "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", + "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", + "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", + "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", + "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", + "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", + "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", + "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", + "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", + "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", + "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", + "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", + "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", + "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", + "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", + "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", + "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", + "SUPABASE_DB_SETTINGS_WORK_MEM", + ]; - describe("legacyResolveDbSettingsEnvOverrides", () => { - const ALL_OVERRIDE_NAMES = [ - "SUPABASE_DB_SETTINGS_EFFECTIVE_CACHE_SIZE", - "SUPABASE_DB_SETTINGS_LOGICAL_DECODING_WORK_MEM", - "SUPABASE_DB_SETTINGS_MAINTENANCE_WORK_MEM", - "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", - "SUPABASE_DB_SETTINGS_MAX_LOCKS_PER_TRANSACTION", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_MAINTENANCE_WORKERS", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS", - "SUPABASE_DB_SETTINGS_MAX_PARALLEL_WORKERS_PER_GATHER", - "SUPABASE_DB_SETTINGS_MAX_REPLICATION_SLOTS", - "SUPABASE_DB_SETTINGS_MAX_SLOT_WAL_KEEP_SIZE", - "SUPABASE_DB_SETTINGS_MAX_STANDBY_ARCHIVE_DELAY", - "SUPABASE_DB_SETTINGS_MAX_STANDBY_STREAMING_DELAY", - "SUPABASE_DB_SETTINGS_MAX_WAL_SIZE", - "SUPABASE_DB_SETTINGS_MAX_WAL_SENDERS", - "SUPABASE_DB_SETTINGS_MAX_WORKER_PROCESSES", - "SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", - "SUPABASE_DB_SETTINGS_SHARED_BUFFERS", - "SUPABASE_DB_SETTINGS_STATEMENT_TIMEOUT", - "SUPABASE_DB_SETTINGS_TRACK_ACTIVITY_QUERY_SIZE", - "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", - "SUPABASE_DB_SETTINGS_WAL_KEEP_SIZE", - "SUPABASE_DB_SETTINGS_WAL_SENDER_TIMEOUT", - "SUPABASE_DB_SETTINGS_WORK_MEM", - ]; + afterEach(() => { + for (const name of ALL_OVERRIDE_NAMES) stubEnv(name, undefined); + }); - afterEach(() => { - for (const name of ALL_OVERRIDE_NAMES) delete process.env[name]; - }); + it("returns the configured settings unchanged when nothing is overridden", () => { + const settings = { shared_buffers: "128MB", max_connections: 100 }; + expect(legacyResolveDbSettingsEnvOverrides(settings, testProjectEnvValues)).toEqual( + settings, + ); + }); - it("returns the configured settings unchanged when nothing is overridden", () => { - const settings = { shared_buffers: "128MB", max_connections: 100 }; - expect(legacyResolveDbSettingsEnvOverrides(settings, undefined)).toEqual(settings); - }); + it("leaves an unconfigured field undefined when nothing is overridden", () => { + expect( + legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues).effective_cache_size, + ).toBeUndefined(); + }); - it("leaves an unconfigured field undefined when nothing is overridden", () => { - expect( - legacyResolveDbSettingsEnvOverrides({}, undefined).effective_cache_size, - ).toBeUndefined(); - }); + it("overrides a string field via the env var", () => { + stubEnv("SUPABASE_DB_SETTINGS_SHARED_BUFFERS", "256MB"); + expect( + legacyResolveDbSettingsEnvOverrides({ shared_buffers: "128MB" }, testProjectEnvValues) + .shared_buffers, + ).toBe("256MB"); + }); - it("overrides a string field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SHARED_BUFFERS"] = "256MB"; - expect( - legacyResolveDbSettingsEnvOverrides({ shared_buffers: "128MB" }, undefined).shared_buffers, - ).toBe("256MB"); - }); + it("sets a string field via the env var even when not configured at all", () => { + stubEnv("SUPABASE_DB_SETTINGS_WORK_MEM", "8MB"); + expect(legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues).work_mem).toBe("8MB"); + }); - it("sets a string field via the env var even when not configured at all", () => { - process.env["SUPABASE_DB_SETTINGS_WORK_MEM"] = "8MB"; - expect(legacyResolveDbSettingsEnvOverrides({}, undefined).work_mem).toBe("8MB"); - }); + it("overrides a uint field via the env var", () => { + stubEnv("SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "200"); + expect( + legacyResolveDbSettingsEnvOverrides({ max_connections: 100 }, testProjectEnvValues) + .max_connections, + ).toBe(200); + }); - it("overrides a uint field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "200"; - expect( - legacyResolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, - ).toBe(200); - }); + it("rejects a non-numeric uint override", () => { + stubEnv("SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "not-a-number"); + expect(() => legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues)).toThrow( + "Invalid db.settings.max_connections", + ); + }); - it("rejects a non-numeric uint override", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; - expect(() => legacyResolveDbSettingsEnvOverrides({}, undefined)).toThrow( - "Invalid db.settings.max_connections", - ); - }); + // `db.settings.*` uint fields decode through the same `strconv.ParseUint(str, 0, 64)` + // base-0 grammar as `legacyEnvOverrideUint`'s callers, not a plain-decimal parse. + it("resolves a 0x-prefixed uint override as hex", () => { + stubEnv("SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "0x10"); + expect( + legacyResolveDbSettingsEnvOverrides({ max_connections: 100 }, testProjectEnvValues) + .max_connections, + ).toBe(16); + }); - // `db.settings.*` uint fields decode through the same `strconv.ParseUint(str, 0, 64)` - // base-0 grammar as `legacyEnvOverrideUint`'s callers, not a plain-decimal parse. - it("resolves a 0x-prefixed uint override as hex", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "0x10"; - expect( - legacyResolveDbSettingsEnvOverrides({ max_connections: 100 }, undefined).max_connections, - ).toBe(16); - }); + it("rejects a uint override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { + stubEnv("SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "18446744073709551616"); + expect(() => legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues)).toThrow( + "Failed reading config: Invalid db.settings.max_connections: 18446744073709551616.", + ); + }); - it("rejects a uint override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "18446744073709551616"; - expect(() => legacyResolveDbSettingsEnvOverrides({}, undefined)).toThrow( - "Failed reading config: Invalid db.settings.max_connections: 18446744073709551616.", - ); - }); + it("overrides the boolean field via the env var", () => { + stubEnv("SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", "true"); + expect( + legacyResolveDbSettingsEnvOverrides( + { track_commit_timestamp: false }, + testProjectEnvValues, + ).track_commit_timestamp, + ).toBe(true); + }); - it("overrides the boolean field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "true"; - expect( - legacyResolveDbSettingsEnvOverrides({ track_commit_timestamp: false }, undefined) - .track_commit_timestamp, - ).toBe(true); - }); + it("rejects a malformed boolean override", () => { + stubEnv("SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", "not-a-bool"); + expect(() => legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues)).toThrow( + LegacyInvalidBoolEnvOverrideError, + ); + }); - it("rejects a malformed boolean override", () => { - process.env["SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP"] = "not-a-bool"; - expect(() => legacyResolveDbSettingsEnvOverrides({}, undefined)).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); - }); + it("overrides the session_replication_role enum field via the env var", () => { + stubEnv("SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", "replica"); + expect( + legacyResolveDbSettingsEnvOverrides( + { session_replication_role: "origin" }, + testProjectEnvValues, + ).session_replication_role, + ).toBe("replica"); + }); - it("overrides the session_replication_role enum field via the env var", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "replica"; - expect( - legacyResolveDbSettingsEnvOverrides({ session_replication_role: "origin" }, undefined) - .session_replication_role, - ).toBe("replica"); - }); + it("leaves session_replication_role undefined when neither configured nor overridden", () => { + expect( + legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues).session_replication_role, + ).toBeUndefined(); + }); - it("leaves session_replication_role undefined when neither configured nor overridden", () => { - expect( - legacyResolveDbSettingsEnvOverrides({}, undefined).session_replication_role, - ).toBeUndefined(); - }); + // `SessionReplicationRole.UnmarshalText` + // hard-rejects anything outside `{origin, replica, local}`. + it("rejects an invalid session_replication_role override", () => { + stubEnv("SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE", "invalid"); + expect(() => legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues)).toThrow( + LegacyInvalidSessionReplicationRoleEnvOverrideError, + ); + expect(() => legacyResolveDbSettingsEnvOverrides({}, testProjectEnvValues)).toThrow( + 'Invalid config for db.settings.session_replication_role: cannot parse "invalid" as one of "origin", "replica", "local"', + ); + }); - // `SessionReplicationRole.UnmarshalText` - // hard-rejects anything outside `{origin, replica, local}`. - it("rejects an invalid session_replication_role override", () => { - process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "invalid"; - expect(() => legacyResolveDbSettingsEnvOverrides({}, undefined)).toThrow( - LegacyInvalidSessionReplicationRoleEnvOverrideError, - ); - expect(() => legacyResolveDbSettingsEnvOverrides({}, undefined)).toThrow( - 'Invalid config for db.settings.session_replication_role: cannot parse "invalid" as one of "origin", "replica", "local"', - ); + it("also honors a projectEnvValues (dotenv) value", () => { + expect( + legacyResolveDbSettingsEnvOverrides({}, { SUPABASE_DB_SETTINGS_SHARED_BUFFERS: "512MB" }) + .shared_buffers, + ).toBe("512MB"); + }); }); - it("also honors a projectEnvValues (dotenv) value", () => { - expect( - legacyResolveDbSettingsEnvOverrides({}, { SUPABASE_DB_SETTINGS_SHARED_BUFFERS: "512MB" }) - .shared_buffers, - ).toBe("512MB"); - }); - }); + describe("auth.signing_keys_path (asymmetric JWT signing)", () => { + const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); - describe("auth.signing_keys_path (asymmetric JWT signing)", () => { - const tempRoot = useLegacyTempWorkdir("supabase-signing-keys-test-"); + it.effect("signs anon/service_role with the first RS256 key in the file", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); - it("signs anon/service_role with the first RS256 key in the file", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); + const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; + const publicKey = yield* tryPromiseEffect(() => importJWK(publicJwk, "RS256")); + const { payload, protectedHeader } = yield* tryPromiseEffect(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); - const publicJwk = { ...jwk, d: undefined, p: undefined, q: undefined, dp: undefined }; - const publicKey = await importJWK(publicJwk, "RS256"); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "RS256", kid: "test-rsa-kid" }); + const serviceRole = yield* tryPromiseEffect(() => + jwtVerify(values.serviceRoleKey, publicKey), + ); + expect(serviceRole.payload).toMatchObject({ role: "service_role" }); + }), + ); - const serviceRole = await jwtVerify(values.serviceRoleKey, publicKey); - expect(serviceRole.payload).toMatchObject({ role: "service_role" }); - }); + it.effect("resolves a relative signing_keys_path against <workdir>/supabase", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + expect(values.anonKey.split(".")).toHaveLength(3); + }), + ); - it("resolves a relative signing_keys_path against <workdir>/supabase", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "./signing_keys.json" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey.split(".")).toHaveLength(3); - }); + it.effect("uses an absolute signing_keys_path as-is, without joining the workdir", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); + const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + "/some/unrelated/workdir", + ); + expect(values.anonKey.split(".")).toHaveLength(3); + }), + ); - it("uses an absolute signing_keys_path as-is, without joining the workdir", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const absolutePath = join(tempRoot.current, "supabase", "signing_keys.json"); - const config = baseConfig({ auth: { signing_keys_path: absolutePath } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", "/some/unrelated/workdir"); - expect(values.anonKey.split(".")).toHaveLength(3); - }); + it.effect("still prefers an explicit anon_key/service_role_key over signing keys", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { + signing_keys_path: "signing_keys.json", + anon_key: "configured-anon", + service_role_key: "configured-service-role", + }, + }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + expect(values.anonKey).toBe("configured-anon"); + expect(values.serviceRoleKey).toBe("configured-service-role"); + }), + ); - it("still prefers an explicit anon_key/service_role_key over signing keys", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - const config = baseConfig({ - auth: { - signing_keys_path: "signing_keys.json", - anon_key: "configured-anon", - service_role_key: "configured-service-role", - }, - }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey).toBe("configured-anon"); - expect(values.serviceRoleKey).toBe("configured-service-role"); - }); + it.effect( + "falls back to HMAC signing when signing_keys_path resolves to an empty array", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, []); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + const [, payload] = values.anonKey.split("."); + expect(decodeJson(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ + iss: "supabase-demo", + }); + }), + ); - it("falls back to HMAC signing when signing_keys_path resolves to an empty array", () => { - writeSigningKeys(tempRoot.current, []); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - const [, payload] = values.anonKey.split("."); - expect(JSON.parse(Buffer.from(payload ?? "", "base64url").toString())).toMatchObject({ - iss: "supabase-demo", - }); - }); + it.effect("throws a Go-worded error when the signing keys file does not exist", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to read signing keys: "); + }), + ); - it("throws a Go-worded error when the signing keys file does not exist", () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", + it.effect("preserves the filesystem error when the signing keys path is a directory", () => + Effect.gen(function* () { + const signingKeysPath = join(tempRoot.current, "supabase", "signing_keys.json"); + yield* makeDirectoryEffect(signingKeysPath); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("EISDIR"); + }), + ); + + it.effect("throws a Go-worded error when the signing keys file is malformed JSON", () => + Effect.gen(function* () { + const supabaseDir = join(tempRoot.current, "supabase"); + yield* makeDirectoryEffect(supabaseDir); + yield* writeFileEffect(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to decode signing keys: "); + }), + ); + + it.effect("throws when the first key uses an unsupported algorithm", () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("unsupported algorithm: RS512"); + }), ); - }); - it("throws a Go-worded error when the signing keys file is malformed JSON", () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to decode signing keys: ", + // `Validate` only opens/parses `signing_keys_path` inside + // `if c.Auth.Enabled` — a disabled + // auth section never touches the file, however stale or missing it is. + it.effect("skips reading a missing signing_keys_path when auth is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), ); - }); - it("throws when the first key uses an unsupported algorithm", () => { - writeSigningKeys(tempRoot.current, [{ ...generateRsaJwk(), alg: "RS512" }]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "unsupported algorithm: RS512", + it.effect( + "skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", + () => + Effect.gen(function* () { + const supabaseDir = join(tempRoot.current, "supabase"); + yield* makeDirectoryEffect(supabaseDir); + yield* writeFileEffect(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + // Go's `generateJWT` checks `len(a.SigningKeysPath) > 0 && + // len(a.SigningKeys) > 0`, NOT `auth.enabled` — `a.SigningKeys` is never empty (it keeps + // its `NewConfig()`-seeded default when the file read is skipped), so a disabled-auth + // config with a configured path still signs with the default ES256 key, not HMAC. + const publicKey = yield* tryPromiseEffect(() => + importJWK( + { ...LEGACY_DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, + "ES256", + ), + ); + const { payload, protectedHeader } = yield* tryPromiseEffect(() => + jwtVerify(values.anonKey, publicKey), + ); + expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); + expect(protectedHeader).toMatchObject({ + alg: "ES256", + kid: LEGACY_DEFAULT_SIGNING_KEY.kid, + }); + }), ); - }); - // `Validate` only opens/parses `signing_keys_path` inside - // `if c.Auth.Enabled` — a disabled - // auth section never touches the file, however stale or missing it is. - it("skips reading a missing signing_keys_path when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "missing.json" }, + describe("SUPABASE_AUTH_ENABLED env override", () => { + // `c.Auth.Enabled` is Viper-bound like any other field, + // so `Validate`'s `if c.Auth.Enabled` gate + // reads the POST-override value, not raw + // TOML — a stale/missing signing_keys_path must be skipped when auth is + // disabled only via env/dotenv, and read when auth is enabled only via + // env/dotenv despite TOML saying otherwise. + afterEach(() => { + stubEnv("SUPABASE_AUTH_ENABLED", undefined); + }); + + it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { + stubEnv("SUPABASE_AUTH_ENABLED", "false"); + const config = baseConfig({ + auth: { enabled: true, signing_keys_path: "missing.json" }, + }); + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), + ).not.toThrow(); + }); + + it.effect( + "reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", + () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_ENABLED", "true"); + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const values = yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + ); + expect(values.anonKey.split(".")).toHaveLength(3); + }), + ); + + it("rejects a malformed override instead of falling back to the configured value", () => { + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "missing.json" }, + }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }); }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); }); - it("skips reading a malformed signing_keys_path when auth is disabled, but still signs asymmetrically with the default key", async () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - // Go's `generateJWT` checks `len(a.SigningKeysPath) > 0 && - // len(a.SigningKeys) > 0`, NOT `auth.enabled` — `a.SigningKeys` is never empty (it keeps - // its `NewConfig()`-seeded default when the file read is skipped), so a disabled-auth - // config with a configured path still signs with the default ES256 key, not HMAC. - const publicKey = await importJWK( - { ...LEGACY_DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, - "ES256", - ); - const { payload, protectedHeader } = await jwtVerify(values.anonKey, publicKey); - expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); - expect(protectedHeader).toMatchObject({ alg: "ES256", kid: LEGACY_DEFAULT_SIGNING_KEY.kid }); - }); + describe("auth.site_url (required field in config)", () => { + // The pure empty/set/disabled assertions moved to `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / + // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. + describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { + afterEach(() => { + stubEnv("SUPABASE_AUTH_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_SITE_URL", undefined); + }); - describe("SUPABASE_AUTH_ENABLED env override", () => { - // `c.Auth.Enabled` is Viper-bound like any other field, - // so `Validate`'s `if c.Auth.Enabled` gate - // reads the POST-override value, not raw - // TOML — a stale/missing signing_keys_path must be skipped when auth is - // disabled only via env/dotenv, and read when auth is enabled only via - // env/dotenv despite TOML saying otherwise. - afterEach(() => { - delete process.env["SUPABASE_AUTH_ENABLED"]; - }); + it("rejects an empty site_url when auth is enabled only via env", () => { + stubEnv("SUPABASE_AUTH_ENABLED", "true"); + const config = baseConfig({ auth: { enabled: false, site_url: "" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Missing required field in config: auth.site_url", + ); + }); - it("skips reading a missing signing_keys_path when auth is disabled only via env", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "false"; - const config = baseConfig({ - auth: { enabled: true, signing_keys_path: "missing.json" }, + it("does not throw when auth is disabled only via env, however empty site_url is", () => { + stubEnv("SUPABASE_AUTH_ENABLED", "false"); + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); - it("reads signing_keys_path when auth is enabled only via env despite TOML saying disabled", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "true"; - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + it("accepts an env-provided site_url overriding an empty config.toml value", () => { + stubEnv("SUPABASE_AUTH_SITE_URL", "http://localhost:4000"); + const config = baseConfig({ auth: { enabled: true, site_url: "" } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - expect(values.anonKey.split(".")).toHaveLength(3); - }); - it("rejects a malformed override instead of falling back to the configured value", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "missing.json" }, + it("exposes the overridden site_url on the returned values, not just for validation", () => { + stubEnv("SUPABASE_AUTH_SITE_URL", "http://localhost:4000"); + const config = baseConfig({ auth: { enabled: true, site_url: "http://127.0.0.1:3000" } }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + expect(values.authSiteUrl).toBe("http://localhost:4000"); }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); }); }); - }); - describe("auth.site_url (required field in config)", () => { - // The pure empty/set/disabled assertions moved to `legacy-config-validate.unit.test.ts` - // (direct `legacyValidateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / - // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. - describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { + describe("auth.* flat scalar env overrides (GoTrue container env, not just validation)", () => { + const AUTH_SCALAR_ENV_KEYS = [ + "SUPABASE_AUTH_JWT_ISSUER", + "SUPABASE_AUTH_JWT_EXPIRY", + "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", + "SUPABASE_AUTH_ENABLE_SIGNUP", + "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", + "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", + "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", + "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", + "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", + "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", + ]; afterEach(() => { - delete process.env["SUPABASE_AUTH_ENABLED"]; - delete process.env["SUPABASE_AUTH_SITE_URL"]; + for (const key of AUTH_SCALAR_ENV_KEYS) stubEnv(key, undefined); }); - it("rejects an empty site_url when auth is enabled only via env", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "true"; - const config = baseConfig({ auth: { enabled: false, site_url: "" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Missing required field in config: auth.site_url", + it("overrides every flat auth.* scalar GoTrue needs, not just the ones Validate checks", () => { + stubEnv("SUPABASE_AUTH_JWT_ISSUER", "https://issuer.example.com"); + stubEnv("SUPABASE_AUTH_JWT_EXPIRY", "7200"); + stubEnv( + "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", + "https://a.example.com,https://b.example.com", ); - }); - - it("does not throw when auth is disabled only via env, however empty site_url is", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "false"; - const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + stubEnv("SUPABASE_AUTH_ENABLE_SIGNUP", "false"); + stubEnv("SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", "true"); + stubEnv("SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", "false"); + stubEnv("SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", "20"); + stubEnv("SUPABASE_AUTH_ENABLE_MANUAL_LINKING", "true"); + stubEnv("SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "12"); + stubEnv("SUPABASE_AUTH_PASSWORD_REQUIREMENTS", "lower_upper_letters_digits"); - it("accepts an env-provided site_url overriding an empty config.toml value", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; - const config = baseConfig({ auth: { enabled: true, site_url: "" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + const config = baseConfig({ + auth: { + jwt_expiry: 3600, + additional_redirect_urls: [], + enable_signup: true, + enable_anonymous_sign_ins: false, + enable_refresh_token_rotation: true, + refresh_token_reuse_interval: 10, + enable_manual_linking: false, + minimum_password_length: 6, + password_requirements: "", + }, + }); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + + expect(values.authJwtIssuer).toBe("https://issuer.example.com"); + expect(values.authJwtExpiry).toBe(7200); + expect(values.authAdditionalRedirectUrls).toEqual([ + "https://a.example.com", + "https://b.example.com", + ]); + expect(values.authEnableSignup).toBe(false); + expect(values.authEnableAnonymousSignIns).toBe(true); + expect(values.authEnableRefreshTokenRotation).toBe(false); + expect(values.authRefreshTokenReuseInterval).toBe(20); + expect(values.authEnableManualLinking).toBe(true); + expect(values.authMinimumPasswordLength).toBe(12); + expect(values.authPasswordRequirements).toBe("lower_upper_letters_digits"); }); - it("exposes the overridden site_url on the returned values, not just for validation", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "http://localhost:4000"; - const config = baseConfig({ auth: { enabled: true, site_url: "http://127.0.0.1:3000" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - expect(values.authSiteUrl).toBe("http://localhost:4000"); + it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override, matching Go's UnmarshalText", () => { + stubEnv("SUPABASE_AUTH_PASSWORD_REQUIREMENTS", "bogus"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid auth.password_requirements: bogus", + ); }); }); - }); - describe("auth.* flat scalar env overrides (GoTrue container env, not just validation)", () => { - const AUTH_SCALAR_ENV_KEYS = [ - "SUPABASE_AUTH_JWT_ISSUER", - "SUPABASE_AUTH_JWT_EXPIRY", - "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", - "SUPABASE_AUTH_ENABLE_SIGNUP", - "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", - "SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION", - "SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL", - "SUPABASE_AUTH_ENABLE_MANUAL_LINKING", - "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", - "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", - ]; - afterEach(() => { - for (const key of AUTH_SCALAR_ENV_KEYS) delete process.env[key]; - }); + // auth.captcha/passkey/webauthn/hook/smtp REQUIRED-FIELD checks (the actual `enabled` ⇒ + // provider/secret/uri/host/etc. logic) live entirely in `legacy-config-validate.unit.test.ts` + // (direct `legacyValidateResolvedConfig` calls). Only the SUPABASE_*-env-override MECHANICS + // this resolver owns — layering an env/dotenv value on top of the TOML-decoded or + // raw-document-derived value before that validation ever runs — are tested here, same split as + // `auth.site_url` above. - it("overrides every flat auth.* scalar GoTrue needs, not just the ones Validate checks", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://issuer.example.com"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "7200"; - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = - "https://a.example.com,https://b.example.com"; - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "false"; - process.env["SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS"] = "true"; - process.env["SUPABASE_AUTH_ENABLE_REFRESH_TOKEN_ROTATION"] = "false"; - process.env["SUPABASE_AUTH_REFRESH_TOKEN_REUSE_INTERVAL"] = "20"; - process.env["SUPABASE_AUTH_ENABLE_MANUAL_LINKING"] = "true"; - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "12"; - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "lower_upper_letters_digits"; - - const config = baseConfig({ - auth: { - jwt_expiry: 3600, - additional_redirect_urls: [], - enable_signup: true, - enable_anonymous_sign_ins: false, - enable_refresh_token_rotation: true, - refresh_token_reuse_interval: 10, - enable_manual_linking: false, - minimum_password_length: 6, - password_requirements: "", - }, + describe("auth.captcha env overrides", () => { + // `auth.captcha.*` is Viper-bound like any other nested field once `[auth.captcha]` is + // present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`). + afterEach(() => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", undefined); + stubEnv("SUPABASE_AUTH_CAPTCHA_SECRET", undefined); }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - - expect(values.authJwtIssuer).toBe("https://issuer.example.com"); - expect(values.authJwtExpiry).toBe(7200); - expect(values.authAdditionalRedirectUrls).toEqual([ - "https://a.example.com", - "https://b.example.com", - ]); - expect(values.authEnableSignup).toBe(false); - expect(values.authEnableAnonymousSignIns).toBe(true); - expect(values.authEnableRefreshTokenRotation).toBe(false); - expect(values.authRefreshTokenReuseInterval).toBe(20); - expect(values.authEnableManualLinking).toBe(true); - expect(values.authMinimumPasswordLength).toBe(12); - expect(values.authPasswordRequirements).toBe("lower_upper_letters_digits"); - }); - it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override, matching Go's UnmarshalText", () => { - process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "bogus"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid auth.password_requirements: bogus", - ); - }); - }); - - // auth.captcha/passkey/webauthn/hook/smtp REQUIRED-FIELD checks (the actual `enabled` ⇒ - // provider/secret/uri/host/etc. logic) live entirely in `legacy-config-validate.unit.test.ts` - // (direct `legacyValidateResolvedConfig` calls). Only the SUPABASE_*-env-override MECHANICS - // this resolver owns — layering an env/dotenv value on top of the TOML-decoded or - // raw-document-derived value before that validation ever runs — are tested here, same split as - // `auth.site_url` above. - - describe("auth.captcha env overrides", () => { - // `auth.captcha.*` is Viper-bound like any other nested field once `[auth.captcha]` is - // present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`). - afterEach(() => { - delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; - delete process.env["SUPABASE_AUTH_CAPTCHA_SECRET"]; - }); - - it("rejects a captcha section enabled only via env with no provider", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const config = baseConfig({ auth: { captcha: { enabled: false } } }); - const document = { auth: { captcha: { enabled: false } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.captcha.provider"); - }); - - it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "false"; - const config = baseConfig({ auth: { captcha: { enabled: true } } }); - const document = { auth: { captcha: { enabled: true } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("accepts env-provided provider/secret overriding an enabled captcha section", () => { - process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "hcaptcha"; - process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "shh"; - const config = baseConfig({ auth: { captcha: { enabled: true } } }); - const document = { auth: { captcha: { enabled: true } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); - - it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { - process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); - - describe("auth.passkey / auth.webauthn env overrides", () => { - // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like any other nested field once - // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml. Both are read from the raw - // `document` (5th param), same as the presence-based defaulting above, so these tests thread - // a `document` object through explicitly instead of relying on `baseConfig`'s decoded schema - // (which has no `passkey`/`webauthn` fields at all). - afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; - }); + it("rejects a captcha section enabled only via env with no provider", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "true"); + const config = baseConfig({ auth: { captcha: { enabled: false } } }); + const document = { auth: { captcha: { enabled: false } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.captcha.provider"); + }); - it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { passkey: { enabled: false } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", - ); - }); + it("does not throw when an incomplete enabled captcha section is disabled only via env", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "false"); + const config = baseConfig({ auth: { captcha: { enabled: true } } }); + const document = { auth: { captcha: { enabled: true } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); - it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = - "http://localhost:3000,http://localhost:3001"; - const config = baseConfig(); - const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); + it("accepts env-provided provider/secret overriding an enabled captcha section", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_PROVIDER", "hcaptcha"); + stubEnv("SUPABASE_AUTH_CAPTCHA_SECRET", "shh"); + const config = baseConfig({ auth: { captcha: { enabled: true } } }); + const document = { auth: { captcha: { enabled: true } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); - it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { - process.env["SUPABASE_AUTH_PASSKEY_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + it("does not synthesize a captcha section purely from an env override when [auth.captcha] is absent", () => { + stubEnv("SUPABASE_AUTH_CAPTCHA_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); - it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { - // e.g. a still-literal `env(VAR)` placeholder when the referenced var was never set, or a - // typo — Go's `strconv.ParseBool` hard-rejects this during `Config.Load`, it never silently - // treats it as `false`. - const config = baseConfig(); - const document = { auth: { passkey: { enabled: "not-a-bool" } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow('cannot parse "not-a-bool" as a bool'); - }); - }); + describe("auth.passkey / auth.webauthn env overrides", () => { + // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like any other nested field once + // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml. Both are read from the raw + // `document` (5th param), same as the presence-based defaulting above, so these tests thread + // a `document` object through explicitly instead of relying on `baseConfig`'s decoded schema + // (which has no `passkey`/`webauthn` fields at all). + afterEach(() => { + stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", undefined); + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined); + }); - describe("auth.hook.* env overrides", () => { - // `auth.hook.<type>.*` is Viper-bound like any other nested field once `[auth.hook.<type>]` - // is present in config.toml. `@supabase/config`'s hook schema always decodes a default - // `{ enabled: false }` regardless of file presence, so — like passkey/webauthn above — the - // presence gate is read from the raw `document`, not the decoded `config`. - afterEach(() => { - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; - delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS"]; - }); + it("rejects a passkey section enabled only via env with no [auth.webauthn] section", () => { + stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", "true"); + const config = baseConfig(); + const document = { auth: { passkey: { enabled: false } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow( + "Missing required config section: auth.webauthn (required when auth.passkey.enabled is true)", + ); + }); - it("rejects a hook section enabled only via env with no uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { hook: { send_email: { enabled: false } } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.hook.send_email.uri"); - }); + it("accepts env-provided rp_id/rp_origins overriding an incomplete [auth.webauthn] section", () => { + stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", "true"); + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", "localhost"); + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", "http://localhost:3000,http://localhost:3001"); + const config = baseConfig(); + const document = { auth: { passkey: { enabled: false }, webauthn: {} } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); - it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"] = "pg-functions://postgres/auth/hook"; - const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); - const document = { auth: { hook: { send_email: { enabled: true } } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); + it("does not synthesize a passkey section purely from an env override when [auth.passkey] is absent from the document", () => { + stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { + // e.g. a still-literal `env(VAR)` placeholder when the referenced var was never set, or a + // typo — Go's `strconv.ParseBool` hard-rejects this during `Config.Load`, it never silently + // treats it as `false`. + const config = baseConfig(); + const document = { auth: { passkey: { enabled: "not-a-bool" } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow('cannot parse "not-a-bool" as a bool'); + }); }); - }); - describe("auth.email.smtp env overrides", () => { - // `auth.email.smtp.*` is Viper-bound like any other nested field once `[auth.email.smtp]` - // is present in config.toml — layered on top of the presence-aware raw-document read that - // already exists here for Go's presence-based `enabled` default. - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"]; - delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"]; - }); + describe("auth.hook.* env overrides", () => { + // `auth.hook.<type>.*` is Viper-bound like any other nested field once `[auth.hook.<type>]` + // is present in config.toml. `@supabase/config`'s hook schema always decodes a default + // `{ enabled: false }` regardless of file presence, so — like passkey/webauthn above — the + // presence gate is read from the raw `document`, not the decoded `config`. + afterEach(() => { + stubEnv("SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_HOOK_SEND_EMAIL_URI", undefined); + stubEnv("SUPABASE_AUTH_HOOK_SEND_EMAIL_SECRETS", undefined); + }); - it("rejects an smtp section enabled only via env with no host", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: false } } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow("Missing required field in config: auth.email.smtp.host"); - }); + it("rejects a hook section enabled only via env with no uri", () => { + stubEnv("SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED", "true"); + const config = baseConfig(); + const document = { auth: { hook: { send_email: { enabled: false } } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.hook.send_email.uri"); + }); - it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "587"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: true } } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).not.toThrow(); - }); + it("accepts an env-provided uri overriding a TOML-enabled hook missing its uri", () => { + stubEnv("SUPABASE_AUTH_HOOK_SEND_EMAIL_URI", "pg-functions://postgres/auth/hook"); + const config = baseConfig({ auth: { hook: { send_email: { enabled: true } } } }); + const document = { auth: { hook: { send_email: { enabled: true } } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); - it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"] = "smtp.example.com"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PORT"] = "not-a-port"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_USER"] = "user"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "pass"; - process.env["SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL"] = "admin@example.com"; - const config = baseConfig(); - const document = { auth: { email: { smtp: { enabled: true } } } }; - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow(LegacyInvalidPortEnvOverrideError); + it("does not synthesize a hook enablement purely from an env override when the section is absent from the document", () => { + stubEnv("SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); - it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { - process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); - }); + describe("auth.email.smtp env overrides", () => { + // `auth.email.smtp.*` is Viper-bound like any other nested field once `[auth.email.smtp]` + // is present in config.toml — layered on top of the presence-aware raw-document read that + // already exists here for Go's presence-based `enabled` default. + afterEach(() => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PORT", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PASS", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", undefined); + }); - describe("auth.mfa env overrides", () => { - // `auth.mfa.<factor>.*` is Viper-bound unconditionally (value-typed struct fields, never - // `nil`) — unlike hooks/smtp above, no raw-document presence gate is needed; see the block - // comment above the `mfa` array in legacy-local-config-values.ts. - afterEach(() => { - delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; - delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; - }); + it("rejects an smtp section enabled only via env with no host", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ENABLED", "true"); + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: false } } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow("Missing required field in config: auth.email.smtp.host"); + }); - it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", - ); - }); + it("accepts env-provided host/port/user/pass/admin_email overriding an enabled-but-incomplete smtp section", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", "smtp.example.com"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PORT", "587"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", "user"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PASS", "pass"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", "admin@example.com"); + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: true } } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).not.toThrow(); + }); - it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("rejects an invalid SUPABASE_AUTH_EMAIL_SMTP_PORT override", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_HOST", "smtp.example.com"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PORT", "not-a-port"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_USER", "user"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_PASS", "pass"); + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL", "admin@example.com"); + const config = baseConfig(); + const document = { auth: { email: { smtp: { enabled: true } } } }; + expect(() => + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + ).toThrow(LegacyConfigValidateError); + }); - it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { - process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - LegacyInvalidBoolEnvOverrideError, - ); + it("does not synthesize an smtp section purely from an env override when [auth.email.smtp] is absent from the document", () => { + stubEnv("SUPABASE_AUTH_EMAIL_SMTP_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); }); - }); - describe("auth.third_party env overrides", () => { - // Same value-typed-struct reasoning as auth.mfa above — including `workos`, whose default - // template omits `[auth.third_party.workos]` entirely yet is still unconditionally overridable. - afterEach(() => { - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; - delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; - }); + describe("auth.mfa env overrides", () => { + // `auth.mfa.<factor>.*` is Viper-bound unconditionally (value-typed struct fields, never + // `nil`) — unlike hooks/smtp above, no raw-document presence gate is needed; see the block + // comment above the `mfa` array in legacy-local-config-values.ts. + afterEach(() => { + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", undefined); + }); - it("rejects a third-party provider enabled only via env with no required field configured", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "true"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); + it("rejects an env-enabled enroll factor left at its TOML-decoded verify default", () => { + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid MFA config: auth.mfa.totp.enroll_enabled requires verify_enabled", + ); + }); - it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; - const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); - }); + it("accepts an env-enabled enroll factor when verify is also env-enabled", () => { + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "true"); + stubEnv("SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + }); - it("does not enable a third-party provider purely from a required-field env override", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"] = "my-project"; - const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + it("rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED override", () => { + stubEnv("SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED", "not-a-bool"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + 'Invalid config for auth.mfa.totp.enroll_enabled: cannot parse "not-a-bool" as a bool', + ); + }); }); - }); - describe("auth.email.template/notification (content_path validation)", () => { - // `(e *email) validate(fsys)`, - // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. - const tempRoot = useLegacyTempWorkdir("supabase-email-templates-test-"); + describe("auth.third_party env overrides", () => { + // Same value-typed-struct reasoning as auth.mfa above — including `workos`, whose default + // template omits `[auth.third_party.workos]` entirely yet is still unconditionally overridable. + afterEach(() => { + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", undefined); + }); - it("rejects a template content_path pointing at a missing file", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "missing-invite.html" } } }, - }, + it("rejects a third-party provider enabled only via env with no required field configured", () => { + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", "true"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", - ); - }); - it("resolves a relative template content_path against the workdir itself, not <workdir>/supabase", () => { - writeFileSync(join(tempRoot.current, "invite.html"), "<html></html>"); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, + it("accepts an env-provided project_id overriding a TOML-enabled firebase provider", () => { + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", "my-project"); + const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); - it("does not throw a template with no content_path configured", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, + it("does not enable a third-party provider purely from a required-field env override", () => { + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID", "my-project"); + const config = baseConfig(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); }); - it("rejects an enabled notification content_path pointing at a missing file", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: true, content_path: "missing.html" } }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", + describe("auth.email.template/notification (content_path validation)", () => { + // `(e *email) validate(fsys)`, + // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. + const tempRoot = useLegacyTempWorkdir("supabase-email-templates-test-"); + + it.effect("rejects a template content_path pointing at a missing file", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "missing-invite.html" } } }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }), ); - }); - it("resolves a relative notification content_path against the workdir", () => { - const templateDir = join(tempRoot.current, "supabase", "templates"); - mkdirSync(templateDir, { recursive: true }); - writeFileSync(join(templateDir, "pw-changed.html"), "<html></html>"); - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { + it.effect( + "resolves a relative template content_path against the workdir itself, not <workdir>/supabase", + () => + Effect.gen(function* () { + yield* writeFileEffect(join(tempRoot.current, "invite.html"), "<html></html>"); + const config = baseConfig({ + auth: { enabled: true, - content_path: "supabase/templates/pw-changed.html", + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, }, - }, - }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("does not throw a disabled notification's missing content_path", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { enabled: false, content_path: "missing.html" }, + it.effect("does not throw a template with no content_path configured", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, }, - }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); - - it("does not throw a missing template content_path when auth is disabled", () => { - const config = baseConfig({ - auth: { enabled: false, email: { template: { invite: { content_path: "missing.html" } } } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - // Divergence #2 (see `legacy-config-validate.ts`'s port-plan notes): Go's asymmetric - // content-vs-content_path exclusivity — a raw `content` key present - // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no - // `content` field to see, so this only fires when the raw `document` (5th param) carries it. - it("rejects a template content key present without content_path", () => { - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current, undefined, { - auth: { email: { template: { invite: { content: "<html>Hi</html>" } } } }, + it.effect("rejects an enabled notification content_path pointing at a missing file", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { password_changed: { enabled: true, content_path: "missing.html" } }, + }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); }), - ).toThrow( - "Invalid config for auth.email.template.invite.content: please use content_path instead", ); - }); - }); - describe("auth.email.template/notification env overrides", () => { - // `auth.email.template.<name>.*`/`auth.email.notification.<name>.*` are Viper-bound like any - // other nested field once the section is present in config.toml. Unlike hook/passkey, no - // extra raw-document presence gate is needed: `email.template`/`email.notification` are - // `Schema.Record`s, so `Object.entries` on the decoded config already reflects presence. - const tempRoot = useLegacyTempWorkdir("supabase-email-template-env-test-"); + it.effect("resolves a relative notification content_path against the workdir", () => + Effect.gen(function* () { + const templateDir = join(tempRoot.current, "supabase", "templates"); + yield* makeDirectoryEffect(templateDir); + yield* writeFileEffect(join(templateDir, "pw-changed.html"), "<html></html>"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { + enabled: true, + content_path: "supabase/templates/pw-changed.html", + }, + }, + }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - afterEach(() => { - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"]; - delete process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"]; - }); + it.effect("does not throw a disabled notification's missing content_path", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("lets an env-provided template content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "invite.html"), "<html></html>"); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect("does not throw a missing template content_path when auth is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: false, + email: { template: { invite: { content_path: "missing.html" } } }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("rejects a notification enabled only via env with a missing content_path file", () => { - // Go applies SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED before - // Auth.Email.validate() decides whether to read content_path — a notification disabled - // in TOML but enabled by env must still be checked. - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: false, content_path: "missing.html" } }, - }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content_path: ", + // Divergence #2 (see `legacy-config-validate.ts`'s port-plan notes): Go's asymmetric + // content-vs-content_path exclusivity — a raw `content` key present + // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no + // `content` field to see, so this only fires when the raw `document` (5th param) carries it. + it.effect("rejects a template content key present without content_path", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current, undefined, { + auth: { email: { template: { invite: { content: "<html>Hi</html>" } } } }, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); + }), ); }); - it("does not validate a notification disabled only via env despite a TOML-enabled section", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "false"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { password_changed: { enabled: true, content_path: "missing.html" } }, - }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + describe("auth.email.template/notification env overrides", () => { + // `auth.email.template.<name>.*`/`auth.email.notification.<name>.*` are Viper-bound like any + // other nested field once the section is present in config.toml. Unlike hook/passkey, no + // extra raw-document presence gate is needed: `email.template`/`email.notification` are + // `Schema.Record`s, so `Object.entries` on the decoded config already reflects presence. + const tempRoot = useLegacyTempWorkdir("supabase-email-template-env-test-"); - it("lets an env-provided notification content_path override a missing TOML content_path", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), "<html></html>"); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "pw-changed.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, + afterEach(() => { + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH", undefined); + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT", undefined); }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); - // Go's Viper `AutomaticEnv` folds a `SUPABASE_AUTH_EMAIL_TEMPLATE_<NAME>_CONTENT`/ - // `_NOTIFICATION_<NAME>_CONTENT` override into `Content *string` before `Config.Validate` - // runs, so it's "present" for the content-vs-content_path exclusivity - // check exactly like a raw TOML `content` key — a bare env override with no content_path - // configured anywhere must be rejected, not silently accepted. - it("rejects a template _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "<html>Hi</html>"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content: please use content_path instead", + it.effect( + "lets an env-provided template content_path override a missing TOML content_path", + () => + Effect.gen(function* () { + yield* writeFileEffect(join(tempRoot.current, "invite.html"), "<html></html>"); + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH", "invite.html"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), ); - }); - it("rejects an enabled notification's _CONTENT env override with no content_path configured", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "<html>Hi</html>"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: true } } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.notification.password_changed.content: please use content_path instead", + it.effect( + "rejects a notification enabled only via env with a missing content_path file", + () => + Effect.gen(function* () { + // Go applies SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED before + // Auth.Email.validate() decides whether to read content_path — a notification disabled + // in TOML but enabled by env must still be checked. + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED", "true"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: false, content_path: "missing.html" }, + }, + }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.notification.password_changed.content_path: ", + ); + }), ); - }); - it("does not validate a disabled notification's _CONTENT env override", () => { - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT"] = "<html>Hi</html>"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: false } } }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect( + "does not validate a notification disabled only via env despite a TOML-enabled section", + () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED", "false"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "missing.html" }, + }, + }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("lets a simultaneous template _CONTENT_PATH env override win over a _CONTENT env override", () => { - writeFileSync(join(tempRoot.current, "invite.html"), "<html></html>"); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "<html>Hi</html>"; - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "invite.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: {} } }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect( + "lets an env-provided notification content_path override a missing TOML content_path", + () => + Effect.gen(function* () { + yield* writeFileEffect(join(tempRoot.current, "pw-changed.html"), "<html></html>"); + stubEnv( + "SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH", + "pw-changed.html", + ); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: true } } }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYn): `content_path` is the field that can - // actually abort resolution here — an ungated override let a stale/missing ambient - // `_CONTENT_PATH` outrank a matched remote's own valid path, and the caller-side file read - // (`readAuthEmailTemplateContent`) then threw, aborting the whole - // `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value `v.Set` - // (override tier, above `AutomaticEnv`) never lets win. - writeFileSync(join(tempRoot.current, "invite.html"), "<html></html>"); - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => - legacyResolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.template.invite.content_path"]), - ), - ).not.toThrow(); - }); + // Go's Viper `AutomaticEnv` folds a `SUPABASE_AUTH_EMAIL_TEMPLATE_<NAME>_CONTENT`/ + // `_NOTIFICATION_<NAME>_CONTENT` override into `Content *string` before `Config.Validate` + // runs, so it's "present" for the content-vs-content_path exclusivity + // check exactly like a raw TOML `content` key — a bare env override with no content_path + // configured anywhere must be rejected, not silently accepted. + it.effect("rejects a template _CONTENT env override with no content_path configured", () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT", "<html>Hi</html>"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.template.invite.content: please use content_path instead", + ); + }), + ); - it("still applies a template _CONTENT_PATH override to a missing file when no remote block matched", () => { - process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { template: { invite: { content_path: "invite.html" } } }, - }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Invalid config for auth.email.template.invite.content_path: ", + it.effect( + "rejects an enabled notification's _CONTENT env override with no content_path configured", + () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT", "<html>Hi</html>"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: true } } }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.notification.password_changed.content: please use content_path instead", + ); + }), ); - }); - it("preserves a remote block's valid notification content_path over a missing-file ambient override", () => { - writeFileSync(join(tempRoot.current, "pw-changed.html"), "<html></html>"); - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH"] = - "missing.html"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { - notification: { - password_changed: { enabled: true, content_path: "pw-changed.html" }, + it.effect("does not validate a disabled notification's _CONTENT env override", () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT", "<html>Hi</html>"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: false } } }, }, - }, - }, - }); - expect(() => - legacyResolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.content_path"]), - ), - ).not.toThrow(); - }); + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { - // `enabled` is a direct `legacyEnvOverrideBool` call, so a malformed ambient override - // throws on its own regardless of the exclusivity/file-read checks above — same bug class - // as `auth.email.enable_signup`/`.enable_confirmations` (review: PRRT_kwDOErm0O86XLAYo). - process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; - const config = baseConfig({ - auth: { - enabled: true, - site_url: "http://localhost:3000", - email: { notification: { password_changed: { enabled: false } } }, - }, - }); - expect(() => - legacyResolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.email.notification.password_changed.enabled"]), - ), - ).not.toThrow(); + it.effect( + "lets a simultaneous template _CONTENT_PATH env override win over a _CONTENT env override", + () => + Effect.gen(function* () { + yield* writeFileEffect(join(tempRoot.current, "invite.html"), "<html></html>"); + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT", "<html>Hi</html>"); + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH", "invite.html"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: {} } }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); + + it.effect( + "preserves a remote block's valid template content_path over a missing-file ambient override", + () => + Effect.gen(function* () { + // Regression (review: PRRT_kwDOErm0O86XLAYn): `content_path` is the field that can + // actually abort resolution here — an ungated override let a stale/missing ambient + // `_CONTENT_PATH` outrank a matched remote's own valid path, and the caller-side file read + // (`readAuthEmailTemplateContent`) then threw, aborting the whole + // `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a value `v.Set` + // (override tier, above `AutomaticEnv`) never lets win. + yield* writeFileEffect(join(tempRoot.current, "invite.html"), "<html></html>"); + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH", "missing.html"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.template.invite.content_path"]), + ); + }), + ); + + it.effect( + "still applies a template _CONTENT_PATH override to a missing file when no remote block matched", + () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH", "missing.html"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { template: { invite: { content_path: "invite.html" } } }, + }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config for auth.email.template.invite.content_path: ", + ); + }), + ); + + it.effect( + "preserves a remote block's valid notification content_path over a missing-file ambient override", + () => + Effect.gen(function* () { + yield* writeFileEffect(join(tempRoot.current, "pw-changed.html"), "<html></html>"); + stubEnv( + "SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_CONTENT_PATH", + "missing.html", + ); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { + notification: { + password_changed: { enabled: true, content_path: "pw-changed.html" }, + }, + }, + }, + }); + yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.content_path"]), + ); + }), + ); }); + + it.effect( + "suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", + () => + Effect.gen(function* () { + // `enabled` is a direct `legacyEnvOverrideBool` call, so a malformed ambient override + // throws on its own regardless of the exclusivity/file-read checks above — same bug class + // as `auth.email.enable_signup`/`.enable_confirmations` (review: PRRT_kwDOErm0O86XLAYo). + stubEnv("SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED", "not-a-bool"); + const config = baseConfig({ + auth: { + enabled: true, + site_url: "http://localhost:3000", + email: { notification: { password_changed: { enabled: false } } }, + }, + }); + yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + WORKDIR, + undefined, + undefined, + new Set(["auth.email.notification.password_changed.enabled"]), + ); + }), + ); }); // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) @@ -3034,7 +3384,7 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig(); const document = { auth: { external: { custom: { enabled: true } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow("Missing required field in config: auth.external.custom.client_id"); }); @@ -3044,7 +3394,7 @@ describe("legacyResolveLocalConfigValues", () => { auth: { external: { custom: { enabled: true, client_id: "abc" } } }, }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow("Missing required field in config: auth.external.custom.secret"); }); @@ -3054,7 +3404,7 @@ describe("legacyResolveLocalConfigValues", () => { auth: { external: { apple: { enabled: true, client_id: "abc" } } }, }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); @@ -3062,7 +3412,7 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig(); const document = { auth: { external: { slack: { enabled: true } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); @@ -3070,13 +3420,13 @@ describe("legacyResolveLocalConfigValues", () => { const config = baseConfig(); const document = { auth: { external: { custom: { enabled: false } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); it("skips the check entirely when no document is threaded through", () => { const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); }); @@ -3085,34 +3435,34 @@ describe("legacyResolveLocalConfigValues", () => { // `[auth.external.<name>]` is present in config.toml — same gap the schema's own // `requiredWhenEnabled` check has for KNOWN providers too. afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; - delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"]; + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID", undefined); + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET", undefined); }); it("rejects a provider enabled only via env with no client_id", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED", "true"); const config = baseConfig(); const document = { auth: { external: { custom: { enabled: false } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow("Missing required field in config: auth.external.custom.client_id"); }); it("accepts env-provided client_id/secret overriding a TOML-enabled provider missing both", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"] = "abc"; - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET"] = "shh"; + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID", "abc"); + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_SECRET", "shh"); const config = baseConfig(); const document = { auth: { external: { custom: { enabled: true } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); it("does not synthesize a provider purely from an env override when the section is absent from the document", () => { - process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"] = "true"; + stubEnv("SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED", "true"); const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); }); @@ -3123,44 +3473,44 @@ describe("legacyResolveLocalConfigValues", () => { // for the schema-decoded (pre-env-override) TOML value; this re-runs it against the raw // document with `SUPABASE_AUTH_SMS_*` overrides applied, since the schema never sees them. afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"]; - delete process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"]; - delete process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"]; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", undefined); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", undefined); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", undefined); + stubEnv("SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED", undefined); }); it("rejects a provider enabled only via env with missing required fields", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ENABLED", "true"); const config = baseConfig(); const document = { auth: { sms: { twilio: { enabled: false } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow("Missing required field in config: auth.sms.twilio.account_sid"); }); it("accepts env-provided credentials overriding a TOML-enabled provider missing them", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", "AC123"); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", "MG123"); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", "tok"); const config = baseConfig(); const document = { auth: { sms: { twilio: { enabled: true } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); it("only validates the first enabled provider in Go's fixed priority order", () => { // twilio is disabled via env; messagebird becomes the switch winner and is missing its // required fields — twilio's own (still-missing) fields must never be inspected. - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ENABLED", "false"); + stubEnv("SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED", "true"); const config = baseConfig(); const document = { auth: { sms: { twilio: { enabled: true }, messagebird: { enabled: false } } }, }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow("Missing required field in config: auth.sms.messagebird.originator"); }); @@ -3171,19 +3521,19 @@ describe("legacyResolveLocalConfigValues", () => { // `auth.sms.twilio.*` with Viper even when the user's own config.toml has no `[auth.sms]` // section at all — `SUPABASE_AUTH_SMS_TWILIO_ENABLED` applies with nothing left to supply // the required credentials, so this now fails validation instead of silently doing nothing. - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ENABLED", "true"); const config = baseConfig(); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "Missing required field in config: auth.sms.twilio.account_sid", ); }); it("resolves a fully env-only twilio configuration with no auth.sms.twilio document section", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "AC123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID"] = "MG123"; - process.env["SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"] = "tok"; - const resolved = legacyResolveAuthSms(undefined, baseConfig().auth.sms, undefined); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ENABLED", "true"); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", "AC123"); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID", "MG123"); + stubEnv("SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN", "tok"); + const resolved = legacyResolveAuthSms(undefined, baseConfig().auth.sms, testProjectEnvValues); expect(resolved.twilio.enabled).toBe(true); expect(resolved.twilio.account_sid).toBe("AC123"); expect(resolved.twilio.message_service_sid).toBe("MG123"); @@ -3194,34 +3544,34 @@ describe("legacyResolveLocalConfigValues", () => { // messagebird (like twilio_verify/textlocal/vonage) has no entry at all in Go's default // template, so an absent `[auth.sms.messagebird]` table genuinely means Viper never // registers it — the presence gate is still correct parity for these 4 providers. - process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; + stubEnv("SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED", "true"); const config = baseConfig(); - const resolved = legacyResolveAuthSms(undefined, config.auth.sms, undefined); + const resolved = legacyResolveAuthSms(undefined, config.auth.sms, testProjectEnvValues); expect(resolved.messagebird.enabled).toBe(false); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); }); describe("legacyResolveAuthSms (top-level scalars)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"]; - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", undefined); + stubEnv("SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", undefined); + stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", undefined); + stubEnv("SUPABASE_AUTH_SMS_TEMPLATE", undefined); }); it("overrides enable_signup/enable_confirmations/max_frequency/template with no presence gate", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"] = "true"; - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "Your OTP is {{ .Code }}"; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "true"); + stubEnv("SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", "true"); + stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", "10s"); + stubEnv("SUPABASE_AUTH_SMS_TEMPLATE", "Your OTP is {{ .Code }}"); // A provider must be enabled, or `enable_signup` gets downgraded to `false` regardless of // the override — see the "disables phone login" tests below for that behavior itself. const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.enable_signup).toBe(true); expect(resolved.enable_confirmations).toBe(true); expect(resolved.max_frequency).toBe("10s"); @@ -3235,7 +3585,7 @@ describe("legacyResolveLocalConfigValues", () => { max_frequency: "5s", twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.enable_signup).toBe(true); expect(resolved.max_frequency).toBe("5s"); }); @@ -3243,18 +3593,18 @@ describe("legacyResolveLocalConfigValues", () => { describe("legacyResolveAuthSms (disables phone login with no provider enabled)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", undefined); }); it("downgrades enable_signup to false when configured true with no provider enabled", () => { const configured = { ...baseConfig().auth.sms, enable_signup: true }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.enable_signup).toBe(false); }); it("downgrades an env-overridden enable_signup to false with no provider enabled", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "true"; - const resolved = legacyResolveAuthSms(undefined, baseConfig().auth.sms, undefined); + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "true"); + const resolved = legacyResolveAuthSms(undefined, baseConfig().auth.sms, testProjectEnvValues); expect(resolved.enable_signup).toBe(false); }); @@ -3264,12 +3614,12 @@ describe("legacyResolveLocalConfigValues", () => { enable_signup: true, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.enable_signup).toBe(true); }); it("leaves enable_signup at false when already false with no provider enabled", () => { - const resolved = legacyResolveAuthSms(undefined, baseConfig().auth.sms, undefined); + const resolved = legacyResolveAuthSms(undefined, baseConfig().auth.sms, testProjectEnvValues); expect(resolved.enable_signup).toBe(false); }); }); @@ -3286,37 +3636,42 @@ describe("legacyResolveLocalConfigValues", () => { // whole `legacyResolveLocalConfigValues` call (and the shadow it feeds) on a malformed ambient // override even when a matched remote block already set that field. afterEach(() => { - delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"]; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", undefined); + stubEnv("SUPABASE_AUTH_SMS_VONAGE_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_SECRET", undefined); }); it("suppresses a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when a remote block already set auth.sms.enable_signup", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "not-a-bool"); const configured = { ...baseConfig().auth.sms, enable_signup: true, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; expect(() => - legacyResolveAuthSms(undefined, configured, undefined, new Set(["auth.sms.enable_signup"])), + legacyResolveAuthSms( + undefined, + configured, + testProjectEnvValues, + new Set(["auth.sms.enable_signup"]), + ), ).not.toThrow(); }); it("still rejects a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "not-a-bool"); const configured = { ...baseConfig().auth.sms, enable_signup: true, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; - expect(() => legacyResolveAuthSms(undefined, configured, undefined)).toThrow( + expect(() => legacyResolveAuthSms(undefined, configured, testProjectEnvValues)).toThrow( 'cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_SMS_VONAGE_ENABLED when a remote block already set auth.sms.vonage.enabled", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_ENABLED", "not-a-bool"); const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, @@ -3325,14 +3680,14 @@ describe("legacyResolveLocalConfigValues", () => { legacyResolveAuthSms( undefined, configured, - undefined, + testProjectEnvValues, new Set(["auth.sms.vonage.enabled"]), ), ).not.toThrow(); }); it("prefers a remote-set auth.sms.vonage.api_secret over a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_SECRET", "encrypted:garbage"); const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, @@ -3340,7 +3695,7 @@ describe("legacyResolveLocalConfigValues", () => { const resolved = legacyResolveAuthSms( undefined, configured, - undefined, + testProjectEnvValues, new Set(["auth.sms.vonage.enabled", "auth.sms.vonage.api_secret"]), ); expect(resolved.vonage.api_secret).toBe("remote-secret"); @@ -3350,13 +3705,13 @@ describe("legacyResolveLocalConfigValues", () => { // `vonage` isn't `twilio` (the one provider Go's default template always registers), so the // env override is only consulted at all when the raw `[auth.sms.vonage]` table is present — // same presence gate `providerPresent` already applies for the remote-set case above. - process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_SECRET", "encrypted:garbage"); const authDocument = { sms: { vonage: {} } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, enabled: true, api_secret: "remote-secret" }, }; - expect(() => legacyResolveAuthSms(authDocument, configured, undefined)).toThrow( + expect(() => legacyResolveAuthSms(authDocument, configured, testProjectEnvValues)).toThrow( "failed to parse config: missing private key", ); }); @@ -3365,7 +3720,7 @@ describe("legacyResolveLocalConfigValues", () => { // `originator`/`sender`/`from`/`api_key`) had no `remoteWins` branch at all — `vonage.api_key` // sitting right next to the already-gated `vonage.api_secret` was the clearest tell. it("prefers a remote-set auth.sms.twilio.account_sid over a conflicting SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", "env-sid"); const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, @@ -3377,22 +3732,22 @@ describe("legacyResolveLocalConfigValues", () => { new Set(["auth.sms.twilio.account_sid"]), ); expect(resolved.twilio.account_sid).toBe("remote-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", undefined); }); it("still applies SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", "env-sid"); const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, account_sid: "remote-sid" }, }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.twilio.account_sid).toBe("env-sid"); - delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; + stubEnv("SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", undefined); }); it("prefers a remote-set auth.sms.vonage.from over a conflicting SUPABASE_AUTH_SMS_VONAGE_FROM", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_FROM", "env-from"); const authDocument = { sms: { vonage: { from: "remote-from" } } }; const configured = { ...baseConfig().auth.sms, @@ -3405,23 +3760,23 @@ describe("legacyResolveLocalConfigValues", () => { new Set(["auth.sms.vonage.from"]), ); expect(resolved.vonage.from).toBe("remote-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_FROM", undefined); }); it("still applies SUPABASE_AUTH_SMS_VONAGE_FROM when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"] = "env-from"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_FROM", "env-from"); const authDocument = { sms: { vonage: { from: "remote-from" } } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, from: "remote-from" }, }; - const resolved = legacyResolveAuthSms(authDocument, configured, undefined); + const resolved = legacyResolveAuthSms(authDocument, configured, testProjectEnvValues); expect(resolved.vonage.from).toBe("env-from"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_FROM"]; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_FROM", undefined); }); it("prefers a remote-set auth.sms.vonage.api_key over a conflicting SUPABASE_AUTH_SMS_VONAGE_API_KEY", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_KEY", "env-key"); const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; const configured = { ...baseConfig().auth.sms, @@ -3434,61 +3789,61 @@ describe("legacyResolveLocalConfigValues", () => { new Set(["auth.sms.vonage.api_key"]), ); expect(resolved.vonage.api_key).toBe("remote-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_KEY", undefined); }); it("still applies SUPABASE_AUTH_SMS_VONAGE_API_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"] = "env-key"; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_KEY", "env-key"); const authDocument = { sms: { vonage: { api_key: "remote-key" } } }; const configured = { ...baseConfig().auth.sms, vonage: { ...baseConfig().auth.sms.vonage, api_key: "remote-key" }, }; - const resolved = legacyResolveAuthSms(authDocument, configured, undefined); + const resolved = legacyResolveAuthSms(authDocument, configured, testProjectEnvValues); expect(resolved.vonage.api_key).toBe("env-key"); - delete process.env["SUPABASE_AUTH_SMS_VONAGE_API_KEY"]; + stubEnv("SUPABASE_AUTH_SMS_VONAGE_API_KEY", undefined); }); it("prefers a remote-set auth.sms.template over a conflicting SUPABASE_AUTH_SMS_TEMPLATE", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; + stubEnv("SUPABASE_AUTH_SMS_TEMPLATE", "env template"); const configured = { ...baseConfig().auth.sms, template: "remote template" }; const resolved = legacyResolveAuthSms( undefined, configured, - undefined, + testProjectEnvValues, new Set(["auth.sms.template"]), ); expect(resolved.template).toBe("remote template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + stubEnv("SUPABASE_AUTH_SMS_TEMPLATE", undefined); }); it("still applies SUPABASE_AUTH_SMS_TEMPLATE when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "env template"; + stubEnv("SUPABASE_AUTH_SMS_TEMPLATE", "env template"); const configured = { ...baseConfig().auth.sms, template: "remote template" }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.template).toBe("env template"); - delete process.env["SUPABASE_AUTH_SMS_TEMPLATE"]; + stubEnv("SUPABASE_AUTH_SMS_TEMPLATE", undefined); }); it("prefers a remote-set auth.sms.max_frequency over a conflicting SUPABASE_AUTH_SMS_MAX_FREQUENCY", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; + stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", "5s"); const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; const resolved = legacyResolveAuthSms( undefined, configured, - undefined, + testProjectEnvValues, new Set(["auth.sms.max_frequency"]), ); expect(resolved.max_frequency).toBe("1m"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", undefined); }); it("still applies SUPABASE_AUTH_SMS_MAX_FREQUENCY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "5s"; + stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", "5s"); const configured = { ...baseConfig().auth.sms, max_frequency: "1m" }; - const resolved = legacyResolveAuthSms(undefined, configured, undefined); + const resolved = legacyResolveAuthSms(undefined, configured, testProjectEnvValues); expect(resolved.max_frequency).toBe("5s"); - delete process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"]; + stubEnv("SUPABASE_AUTH_SMS_MAX_FREQUENCY", undefined); }); it("still aborts legacyResolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { @@ -3498,7 +3853,7 @@ describe("legacyResolveLocalConfigValues", () => { // Built by spreading an already-decoded `baseConfig()` (not re-decoding through // `ProjectConfigSchema` via `baseConfig({...})`'s shallow-merge overrides) so `vonage`'s // other schema-required fields (`from`, etc.) keep their valid decoded defaults. - process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_SMS_ENABLE_SIGNUP", "not-a-bool"); const base = baseConfig(); const config: ProjectConfig = { ...base, @@ -3518,11 +3873,11 @@ describe("legacyResolveLocalConfigValues", () => { }, }, }; - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'cannot parse "not-a-bool" as a bool', ); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3539,110 +3894,131 @@ describe("legacyResolveLocalConfigValues", () => { function writeTlsFile(workdir: string, name: string, contents = "dummy") { const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); + return Effect.gen(function* () { + yield* makeDirectoryEffect(supabaseDir); + yield* writeFileEffect(join(supabaseDir, name), contents); + }); } - it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { - // Go's Validate only rejects the "exactly one set" case; - // tls.enabled with nothing configured still loads. - const config = baseConfig({ api: { tls: { enabled: true } } }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect("does not throw when tls.enabled with neither cert_path nor key_path set", () => + Effect.gen(function* () { + // Go's Validate only rejects the "exactly one set" case; + // tls.enabled with nothing configured still loads. + const config = baseConfig({ api: { tls: { enabled: true } } }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); // The "exactly one of cert/key set" presence-only assertions moved to // `legacy-config-validate.unit.test.ts` (direct `legacyValidateResolvedConfig` calls) — // the actual file reads below stay here, since I/O is per-caller. - it("throws a Go-worded error when the configured cert file does not exist", () => { - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); + it.effect("throws a Go-worded error when the configured cert file does not exist", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("failed to read TLS cert: "); + }), + ); - it("throws a Go-worded error when the configured key file does not exist", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS key: ", - ); - }); + it.effect("throws a Go-worded error when the configured key file does not exist", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("failed to read TLS key: "); + }), + ); - it("succeeds when both cert_path and key_path are readable", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect("succeeds when both cert_path and key_path are readable", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("resolves cert_path/key_path against <workdir>/supabase unconditionally, no isAbsolute guard", () => { - // `path.Join` absorbs a leading "/" — unlike - // signing_keys_path, which Go DOES guard with filepath.IsAbs. - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - const config = baseConfig({ - api: { - tls: { - enabled: true, - cert_path: "/cert.pem", - key_path: "/key.pem", - }, - }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect( + "resolves cert_path/key_path against <workdir>/supabase unconditionally, no isAbsolute guard", + () => + Effect.gen(function* () { + // `path.Join` absorbs a leading "/" — unlike + // signing_keys_path, which Go DOES guard with filepath.IsAbs. + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + const config = baseConfig({ + api: { + tls: { + enabled: true, + cert_path: "/cert.pem", + key_path: "/key.pem", + }, + }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); // `Validate` nests the whole TLS branch inside `if c.Api.Enabled` — // a disabled api section never validates cert/key, // however invalid the pairing. - it("skips TLS validation entirely when api is disabled", () => { - const config = baseConfig({ - api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect("skips TLS validation entirely when api is disabled", () => + Effect.gen(function* () { + const config = baseConfig({ + api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); describe("SUPABASE_API_ENABLED / SUPABASE_API_TLS_ENABLED env overrides", () => { afterEach(() => { - delete process.env["SUPABASE_API_ENABLED"]; - delete process.env["SUPABASE_API_TLS_ENABLED"]; + stubEnv("SUPABASE_API_ENABLED", undefined); + stubEnv("SUPABASE_API_TLS_ENABLED", undefined); }); - it("skips TLS validation when api is disabled only via env", () => { - process.env["SUPABASE_API_ENABLED"] = "false"; - const config = baseConfig({ - api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current), - ).not.toThrow(); - }); + it.effect("skips TLS validation when api is disabled only via env", () => + Effect.gen(function* () { + stubEnv("SUPABASE_API_ENABLED", "false"); + const config = baseConfig({ + api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, + }); + yield* resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current); + }), + ); - it("validates TLS when enabled only via env despite TOML saying tls.enabled = false", () => { - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - const config = baseConfig({ - api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "Missing required field in config: api.tls.key_path", - ); - }); + it.effect( + "validates TLS when enabled only via env despite TOML saying tls.enabled = false", + () => + Effect.gen(function* () { + stubEnv("SUPABASE_API_TLS_ENABLED", "true"); + const config = baseConfig({ + api: { tls: { enabled: false, cert_path: "missing-cert.pem" } }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Missing required field in config: api.tls.key_path", + ); + }), + ); }); }); }); @@ -3692,44 +4068,53 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p "SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "SUPABASE_AUTH_PASSWORD_REQUIREMENTS", "SUPABASE_AUTH_PASSKEY_ENABLED", - "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", - "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", - ]) { - delete process.env[name]; - } - }); - - const tempRoot = useLegacyTempWorkdir("supabase-remote-signing-keys-test-"); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - // Regression (review: PRRT_kwDOErm0O86W3Ox_): `legacyResolveConfiguredSigningKeys` — shared - // by this function's own `anonKey`/`serviceRoleKey` asymmetric signing and by - // `legacyResolveLocalJwks` — used to reapply a conflicting env override even when a remote - // block already set `auth.signing_keys_path`, which would have pointed the shadow's - // asymmetric signing at the wrong (env-supplied) file. - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => - legacyResolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["auth.signing_keys_path"]), - ), - ).not.toThrow(); + "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", + "SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", + ]) { + stubEnv(name, undefined); + } }); - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read signing keys: ", - ); - }); + const tempRoot = useLegacyTempWorkdir("supabase-remote-signing-keys-test-"); + + it.effect( + "prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", + () => + Effect.gen(function* () { + // Regression (review: PRRT_kwDOErm0O86W3Ox_): `legacyResolveConfiguredSigningKeys` — shared + // by this function's own `anonKey`/`serviceRoleKey` asymmetric signing and by + // `legacyResolveLocalJwks` — used to reapply a conflicting env override even when a remote + // block already set `auth.signing_keys_path`, which would have pointed the shadow's + // asymmetric signing at the wrong (env-supplied) file. + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "missing-file.json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["auth.signing_keys_path"]), + ); + }), + ); + + it.effect( + "still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "missing-file.json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to read signing keys: "); + }), + ); it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { // Regression (review: PRRT_kwDOErm0O86W2tRi): this function validates `db.major_version` @@ -3738,10 +4123,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // this fix, the validate-only read here still decoded a conflicting env var unconditionally, // so a malformed value the remote block should have made irrelevant failed config loading // outright instead of the command proceeding on the remote's value, matching Go. - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + stubEnv("SUPABASE_DB_MAJOR_VERSION", "abc"); const config = baseConfig({ db: { major_version: 14 } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3753,17 +4138,17 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_DB_MAJOR_VERSION when no remote block matched", () => { - process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; + stubEnv("SUPABASE_DB_MAJOR_VERSION", "abc"); const config = baseConfig({ db: { major_version: 14 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "Invalid db.major_version: abc", ); }); it("prefers a remote-set auth.jwt_secret over a conflicting SUPABASE_AUTH_JWT_SECRET", () => { - process.env["SUPABASE_AUTH_JWT_SECRET"] = "env-supplied-secret-value-1234567890"; + stubEnv("SUPABASE_AUTH_JWT_SECRET", "env-supplied-secret-value-1234567890"); const config = baseConfig({ auth: { jwt_secret: "remote-supplied-secret-1234567890" } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3775,10 +4160,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("prefers a remote-set db.root_key over a conflicting SUPABASE_DB_ROOT_KEY", () => { - process.env["SUPABASE_DB_ROOT_KEY"] = "env-root-key"; + stubEnv("SUPABASE_DB_ROOT_KEY", "env-root-key"); const config = baseConfig(); const document = { db: { root_key: "remote-root-key" } }; - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3799,8 +4184,8 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // otherwise-valid, remote-backed configuration before the shadow was ever created — Go's // `mergeRemoteConfig` sets the whole matched block at viper's OVERRIDE tier, above // `AutomaticEnv`, so the env var is never even consulted once a remote sets this key. - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + stubEnv("SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED", "false"); + stubEnv("SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", "not-a-clerk-domain"); const config = baseConfig({ auth: { enabled: true, @@ -3808,7 +4193,7 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }, }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3820,14 +4205,14 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a conflicting SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; + stubEnv("SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN", "not-a-clerk-domain"); const config = baseConfig({ auth: { enabled: true, third_party: { clerk: { enabled: true, domain: "clerk.example.com" } }, }, }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "Invalid config: auth.third_party.clerk has invalid domain", ); }); @@ -3837,57 +4222,65 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p function writeTlsFile(workdir: string, name: string, contents = "dummy") { const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, name), contents); + return Effect.gen(function* () { + yield* makeDirectoryEffect(supabaseDir); + yield* writeFileEffect(join(supabaseDir, name), contents); + }); } afterEach(() => { - delete process.env["SUPABASE_API_TLS_CERT_PATH"]; - delete process.env["SUPABASE_API_TLS_KEY_PATH"]; - }); - - it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { - // The ambient env vars point at files that don't exist — if they won, `readApiTlsFiles` - // would throw. `mergeRemoteConfig` installs the matched remote block's cert/key - // paths at viper's OVERRIDE tier (above `AutomaticEnv`), so they must win instead and the - // load must succeed using the real, remote-supplied paths. - writeTlsFile(tempRoot.current, "cert.pem"); - writeTlsFile(tempRoot.current, "key.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - process.env["SUPABASE_API_TLS_KEY_PATH"] = "missing-key.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, - }); - expect(() => - legacyResolveLocalConfigValues( - config, - "127.0.0.1", - tempRoot.current, - undefined, - undefined, - new Set(["api.tls.cert_path", "api.tls.key_path"]), - ), - ).not.toThrow(); - }); + stubEnv("SUPABASE_API_TLS_CERT_PATH", undefined); + stubEnv("SUPABASE_API_TLS_KEY_PATH", undefined); + }); + + it.effect( + "prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", + () => + Effect.gen(function* () { + // The ambient env vars point at files that don't exist — if they won, `readApiTlsFiles` + // would throw. `mergeRemoteConfig` installs the matched remote block's cert/key + // paths at viper's OVERRIDE tier (above `AutomaticEnv`), so they must win instead and the + // load must succeed using the real, remote-supplied paths. + yield* writeTlsFile(tempRoot.current, "cert.pem"); + yield* writeTlsFile(tempRoot.current, "key.pem"); + stubEnv("SUPABASE_API_TLS_CERT_PATH", "missing-cert.pem"); + stubEnv("SUPABASE_API_TLS_KEY_PATH", "missing-key.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "key.pem" } }, + }); + yield* resolveLocalConfigValuesEffect( + config, + "127.0.0.1", + tempRoot.current, + undefined, + undefined, + new Set(["api.tls.cert_path", "api.tls.key_path"]), + ); + }), + ); - it("still uses the env override when no remote block matched", () => { - writeTlsFile(tempRoot.current, "cert.pem"); - process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; - const config = baseConfig({ - api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, - }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( - "failed to read TLS cert: ", - ); - }); + it.effect("still uses the env override when no remote block matched", () => + Effect.gen(function* () { + yield* writeTlsFile(tempRoot.current, "cert.pem"); + stubEnv("SUPABASE_API_TLS_CERT_PATH", "missing-cert.pem"); + const config = baseConfig({ + api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "cert.pem" } }, + }); + const exit = yield* Effect.exit( + resolveLocalConfigValuesEffect(config, "127.0.0.1", tempRoot.current), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("failed to read TLS cert: "); + }), + ); }); it("prefers remote-set api.port/api.tls.enabled/api.external_url over conflicting env overrides", () => { - process.env["SUPABASE_API_PORT"] = "9999"; - process.env["SUPABASE_API_TLS_ENABLED"] = "true"; - process.env["SUPABASE_API_EXTERNAL_URL"] = "https://env-should-not-win.test"; + stubEnv("SUPABASE_API_PORT", "9999"); + stubEnv("SUPABASE_API_TLS_ENABLED", "true"); + stubEnv("SUPABASE_API_EXTERNAL_URL", "https://env-should-not-win.test"); const config = baseConfig({ api: { port: 54321, external_url: "", tls: { enabled: false } } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3899,9 +4292,9 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("prefers a remote-set db.port over a conflicting SUPABASE_DB_PORT", () => { - process.env["SUPABASE_DB_PORT"] = "9999"; + stubEnv("SUPABASE_DB_PORT", "9999"); const config = baseConfig({ db: { port: 54322 } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3914,10 +4307,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("prefers remote-set auth.site_url/auth.jwt_expiry over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_SITE_URL"] = "https://env-should-not-win.test"; - process.env["SUPABASE_AUTH_JWT_EXPIRY"] = "9999"; + stubEnv("SUPABASE_AUTH_SITE_URL", "https://env-should-not-win.test"); + stubEnv("SUPABASE_AUTH_JWT_EXPIRY", "9999"); const config = baseConfig({ auth: { site_url: "https://remote.test", jwt_expiry: 3600 } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3930,12 +4323,12 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("prefers remote-set auth.anon_key/auth.service_role_key over conflicting env overrides", () => { - process.env["SUPABASE_AUTH_ANON_KEY"] = "env-anon-key"; - process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"] = "env-service-role-key"; + stubEnv("SUPABASE_AUTH_ANON_KEY", "env-anon-key"); + stubEnv("SUPABASE_AUTH_SERVICE_ROLE_KEY", "env-service-role-key"); const config = baseConfig({ auth: { anon_key: "remote-anon-key", service_role_key: "remote-service-role-key" }, }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3952,10 +4345,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // `legacyValidateResolvedConfig`'s `legacyGoUrlParse` check, which throws on a malformed URL // even though the read itself (`legacyEnvOverride`) never does — same "non-throwing read, // throwing downstream consumer" bug class as `legacyResolveAuthHooks`'s `uri`/`secrets`. - process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; + stubEnv("SUPABASE_STUDIO_API_URL", "http://[::1"); const config = baseConfig({ studio: { api_url: "http://remote.test" } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3971,9 +4364,9 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // decrypted the same way `anon_key`/`service_role_key` above are — an ungated // `legacyEnvOverride` here could let a malformed ambient override outrank a matched remote's // own valid value and throw during decryption. - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + stubEnv("SUPABASE_STUDIO_OPENAI_API_KEY", "encrypted:not-a-real-ciphertext"); const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -3985,9 +4378,9 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_STUDIO_OPENAI_API_KEY when no remote block matched", () => { - process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; + stubEnv("SUPABASE_STUDIO_OPENAI_API_KEY", "encrypted:not-a-real-ciphertext"); const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "failed to parse config: missing private key", ); }); @@ -3996,12 +4389,12 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // Regression: `auth.publishable_key`/`auth.secret_key` are // `config.Secret`-typed exactly like `anon_key`/`service_role_key` above, but were missed // when that sibling pair was gated. - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + stubEnv("SUPABASE_AUTH_PUBLISHABLE_KEY", "encrypted:not-a-real-ciphertext"); + stubEnv("SUPABASE_AUTH_SECRET_KEY", "encrypted:not-a-real-ciphertext"); const config = baseConfig({ auth: { publishable_key: "remote-publishable-key", secret_key: "remote-secret-key" }, }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4014,17 +4407,17 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_AUTH_PUBLISHABLE_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; + stubEnv("SUPABASE_AUTH_PUBLISHABLE_KEY", "encrypted:not-a-real-ciphertext"); const config = baseConfig({ auth: { publishable_key: "remote-publishable-key" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "failed to parse config: missing private key", ); }); it("still rejects a malformed SUPABASE_AUTH_SECRET_KEY when no remote block matched", () => { - process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; + stubEnv("SUPABASE_AUTH_SECRET_KEY", "encrypted:not-a-real-ciphertext"); const config = baseConfig({ auth: { secret_key: "remote-secret-key" } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "failed to parse config: missing private key", ); }); @@ -4033,10 +4426,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // Same validate-only shape as `db.major_version` above — `legacyResolveDbSettingsEnvOverrides` // is threaded `remoteOverrideKeys` here too, not just at its OWN (already-gated) call site // in `legacyResolveDbBootstrapConfig`. - process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; + stubEnv("SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "not-a-number"); const config = baseConfig({ db: { settings: { max_connections: 100 } } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4054,10 +4447,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // var unconditionally, so a malformed value the remote block should have made irrelevant // failed this WHOLE function (and therefore the shadow's `dbPort`/`jwtSecret`/etc. it also // resolves) instead of the command proceeding on the remote's value, matching Go. - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); const config = baseConfig({ auth: { enabled: false } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4069,9 +4462,9 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); const config = baseConfig({ auth: { enabled: false } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', ); }); @@ -4081,10 +4474,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // `LEGACY_ENV_OVERRIDABLE_KEYS` and `analyticsEnabled` is never read by the shadow's own // container inputs, but an ungated `legacyEnvOverrideBool` call still aborts this whole // function on a malformed override the remote block should have made irrelevant. - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_ANALYTICS_ENABLED", "not-a-bool"); const config = baseConfig({ analytics: { enabled: false } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4096,17 +4489,17 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_ANALYTICS_ENABLED when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_ANALYTICS_ENABLED", "not-a-bool"); const config = baseConfig({ analytics: { enabled: false } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for analytics.enabled: cannot parse "not-a-bool" as a bool', ); }); it("prefers a remote-set analytics.gcp_project_id over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_ID", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", "env-project"); const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4115,21 +4508,21 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p new Set(["analytics.gcp_project_id"]), ); expect(values.gcpProjectId).toBe("remote-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", undefined); }); it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_ID when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"] = "env-project"; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", "env-project"); const config = baseConfig({ analytics: { gcp_project_id: "remote-project" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.gcpProjectId).toBe("env-project"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_ID"]; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_ID", undefined); }); it("prefers a remote-set analytics.gcp_project_number over a conflicting SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", "999"); const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4138,21 +4531,21 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p new Set(["analytics.gcp_project_number"]), ); expect(values.gcpProjectNumber).toBe("111"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", undefined); }); it("still applies SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"] = "999"; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", "999"); const config = baseConfig({ analytics: { gcp_project_number: "111" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.gcpProjectNumber).toBe("999"); - delete process.env["SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER"]; + stubEnv("SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", undefined); }); it("prefers a remote-set analytics.gcp_jwt_path over a conflicting SUPABASE_ANALYTICS_GCP_JWT_PATH", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; + stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", "env-key.json"); const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4161,21 +4554,21 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p new Set(["analytics.gcp_jwt_path"]), ); expect(values.gcpJwtPath).toBe("remote-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", undefined); }); it("still applies SUPABASE_ANALYTICS_GCP_JWT_PATH when no remote block matched", () => { - process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"] = "env-key.json"; + stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", "env-key.json"); const config = baseConfig({ analytics: { gcp_jwt_path: "remote-key.json" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.gcpJwtPath).toBe("env-key.json"); - delete process.env["SUPABASE_ANALYTICS_GCP_JWT_PATH"]; + stubEnv("SUPABASE_ANALYTICS_GCP_JWT_PATH", undefined); }); it("prefers a remote-set auth.jwt_issuer over a conflicting SUPABASE_AUTH_JWT_ISSUER", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; + stubEnv("SUPABASE_AUTH_JWT_ISSUER", "https://env.example.com"); const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4184,23 +4577,23 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p new Set(["auth.jwt_issuer"]), ); expect(values.authJwtIssuer).toBe("https://remote.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; + stubEnv("SUPABASE_AUTH_JWT_ISSUER", undefined); }); it("still applies SUPABASE_AUTH_JWT_ISSUER when no remote block matched", () => { - process.env["SUPABASE_AUTH_JWT_ISSUER"] = "https://env.example.com"; + stubEnv("SUPABASE_AUTH_JWT_ISSUER", "https://env.example.com"); const config = baseConfig({ auth: { jwt_issuer: "https://remote.example.com" } }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.authJwtIssuer).toBe("https://env.example.com"); - delete process.env["SUPABASE_AUTH_JWT_ISSUER"]; + stubEnv("SUPABASE_AUTH_JWT_ISSUER", undefined); }); it("prefers a remote-set auth.additional_redirect_urls over a conflicting SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; + stubEnv("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", "https://env.example.com"); const config = baseConfig({ auth: { additional_redirect_urls: ["https://remote.example.com"] }, }); - const values = legacyResolveLocalConfigValues( + const values = resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4209,17 +4602,17 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p new Set(["auth.additional_redirect_urls"]), ); expect(values.authAdditionalRedirectUrls).toEqual(["https://remote.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + stubEnv("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined); }); it("still applies SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS when no remote block matched", () => { - process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"] = "https://env.example.com"; + stubEnv("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", "https://env.example.com"); const config = baseConfig({ auth: { additional_redirect_urls: ["https://remote.example.com"] }, }); - const values = legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR); + const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.authAdditionalRedirectUrls).toEqual(["https://env.example.com"]); - delete process.env["SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS"]; + stubEnv("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined); }); describe("auth.webauthn.rp_id / auth.webauthn.rp_origins — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { @@ -4231,19 +4624,19 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // (empty) value wins and validation throws exactly like `Validate` would for a // `[remotes.*]`-supplied empty field. afterEach(() => { - delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; - delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"]; + stubEnv("SUPABASE_AUTH_PASSKEY_ENABLED", undefined); + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", undefined); + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined); }); it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ID when a remote block already set (empty) auth.webauthn.rp_id", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", "localhost"); const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, }; expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4255,24 +4648,24 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ID when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"] = "localhost"; + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ID", "localhost"); const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "", rp_origins: ["http://x"] } }, }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); it("suppresses a non-empty SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when a remote block already set (empty) auth.webauthn.rp_origins", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", "http://localhost:3000"); const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, }; expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4284,13 +4677,13 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still applies SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS when no remote block matched", () => { - process.env["SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS"] = "http://localhost:3000"; + stubEnv("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", "http://localhost:3000"); const config = baseConfig(); const document = { auth: { passkey: { enabled: true }, webauthn: { rp_id: "localhost", rp_origins: [] } }, }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).not.toThrow(); }); }); @@ -4300,12 +4693,12 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // validation-only `thirdParty` block (distinct from `legacyResolveLocalJwks`'s own, already- // gated `thirdParty` — see that param's doc comment). Auth must be enabled for this block to // run at all. - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", "not-a-bool"); const config = baseConfig({ auth: { enabled: true, third_party: { firebase: { enabled: false } } }, }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4317,11 +4710,11 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED", "not-a-bool"); const config = baseConfig({ auth: { enabled: true, third_party: { firebase: { enabled: false } } }, }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for auth.third_party.firebase.enabled: cannot parse "not-a-bool" as a bool', ); }); @@ -4332,10 +4725,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // `LEGACY_ENV_OVERRIDABLE_KEYS` and `denoVersion` is never read by the shadow's own // container inputs, but an ungated `legacyEnvOverrideDenoVersion` call still aborts this // whole function on a malformed override the remote block should have made irrelevant. - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", "abc"); const config = baseConfig({ edge_runtime: { deno_version: 2 } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4347,9 +4740,9 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when no remote block matched", () => { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; + stubEnv("SUPABASE_EDGE_RUNTIME_DENO_VERSION", "abc"); const config = baseConfig({ edge_runtime: { deno_version: 2 } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "Failed reading config: Invalid edge_runtime.deno_version: abc.", ); }); @@ -4362,10 +4755,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // but an ungated `legacyEnvOverrideBool` call still aborts this whole function — denying // it `apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc. too — on a malformed override the remote // block should have made irrelevant. - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_API_ENABLED", "not-a-bool"); const config = baseConfig({ api: { enabled: false } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4377,9 +4770,9 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_API_ENABLED when no remote block matched", () => { - process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_API_ENABLED", "not-a-bool"); const config = baseConfig({ api: { enabled: false } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for api.enabled: cannot parse "not-a-bool" as a bool', ); }); @@ -4393,10 +4786,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // caller needs has already been resolved" — that's false: this function either returns its // whole object or throws, so ANY unconditional throw anywhere in its body aborts the entire // call, denying the shadow `dbPort`/`jwtSecret`/etc. too, regardless of textual position. - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_STUDIO_ENABLED", "not-a-bool"); const config = baseConfig({ studio: { enabled: false } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4408,18 +4801,18 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_STUDIO_ENABLED when no remote block matched", () => { - process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_STUDIO_ENABLED", "not-a-bool"); const config = baseConfig({ studio: { enabled: false } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for studio.enabled: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_STUDIO_PORT when a remote block already set studio.port", () => { - process.env["SUPABASE_STUDIO_PORT"] = "not-a-port"; + stubEnv("SUPABASE_STUDIO_PORT", "not-a-port"); const config = baseConfig({ studio: { port: 54323 } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4431,10 +4824,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("suppresses a malformed SUPABASE_LOCAL_SMTP_ENABLED when a remote block already set local_smtp.enabled", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_LOCAL_SMTP_ENABLED", "not-a-bool"); const config = baseConfig({ local_smtp: { enabled: false } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4446,18 +4839,18 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_LOCAL_SMTP_ENABLED when no remote block matched", () => { - process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_LOCAL_SMTP_ENABLED", "not-a-bool"); const config = baseConfig({ local_smtp: { enabled: false } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for local_smtp.enabled: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_ENABLE_SIGNUP when a remote block already set auth.enable_signup", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_ENABLE_SIGNUP", "not-a-bool"); const config = baseConfig({ auth: { enable_signup: false } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4469,18 +4862,18 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a malformed SUPABASE_AUTH_ENABLE_SIGNUP when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; + stubEnv("SUPABASE_AUTH_ENABLE_SIGNUP", "not-a-bool"); const config = baseConfig({ auth: { enable_signup: false } }); - expect(() => legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( + expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( 'Invalid config for auth.enable_signup: cannot parse "not-a-bool" as a bool', ); }); it("suppresses a malformed SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH when a remote block already set auth.minimum_password_length", () => { - process.env["SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH"] = "not-a-number"; + stubEnv("SUPABASE_AUTH_MINIMUM_PASSWORD_LENGTH", "not-a-number"); const config = baseConfig({ auth: { minimum_password_length: 8 } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4492,10 +4885,10 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("suppresses a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when a remote block already set experimental.webhooks.enabled", () => { - process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "not-a-bool"; + stubEnv("SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED", "not-a-bool"); const config = baseConfig({ experimental: { webhooks: { enabled: true } } }); expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4513,7 +4906,7 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p // above `AutomaticEnv`, so the remote's valid uri must win and validation must pass — before // this fix, the ungated env read won instead and `legacyValidateResolvedConfig`'s scheme // check rejected a linked diff/pull that Go would have accepted. - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", "ftp://example.com"); const config = baseConfig({ auth: { hook: { @@ -4527,7 +4920,7 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; expect(() => - legacyResolveLocalConfigValues( + resolveLocalConfigValues( config, "127.0.0.1", WORKDIR, @@ -4539,7 +4932,7 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); it("still rejects a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when no remote block matched that leaf", () => { - process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; + stubEnv("SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", "ftp://example.com"); const config = baseConfig({ auth: { hook: { @@ -4549,70 +4942,103 @@ describe("legacyResolveLocalConfigValues — remoteOverrideKeys (linked shadow p }); const document = { auth: { hook: { custom_access_token: { enabled: true } } } }; expect(() => - legacyResolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), + resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); }); }); describe("legacyResolveLocalJwks", () => { const tempRoot = useLegacyTempWorkdir("supabase-local-jwks-test-"); + const resolveJwksEffect = (...args: Parameters<typeof legacyResolveLocalJwks>) => + legacyResolveLocalJwks(...args).pipe( + Effect.provide( + Layer.mergeAll( + BunServices.layer, + FetchHttpClient.layer, + Layer.succeed(FetchHttpClient.Fetch, globalThis.fetch), + ), + ), + ); - it("includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", async () => { - // `a.SigningKeys` defaults to this single ES256 key at `NewConfig()` time, - // unconditionally — `ResolveJWKS` always publishes it - // (in public form) unless a configured `signing_keys_path` file overrides it. - const config = baseConfig(); - const jwks = await legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)); - expect(JSON.parse(jwks)).toEqual({ - keys: [ - { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - }, - { kty: "oct", k: Buffer.from("a".repeat(32)).toString("base64url") }, - ], - }); - }); + it.effect( + "includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", + () => + Effect.gen(function* () { + // `a.SigningKeys` defaults to this single ES256 key at `NewConfig()` time, + // unconditionally — `ResolveJWKS` always publishes it + // (in public form) unless a configured `signing_keys_path` file overrides it. + const config = baseConfig(); + const jwks = yield* resolveJwksEffect( + config, + tempRoot.current, + "a".repeat(32), + testProjectEnvValues, + ); + expect(decodeJson(jwks)).toEqual({ + keys: [ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + }, + { kty: "oct", k: Buffer.from("a".repeat(32)).toString("base64url") }, + ], + }); + }), + ); - it("publishes the public form of every signing key and omits the oct fallback", async () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; - - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ - kty: "RSA", - kid: "test-rsa-kid", - n: jwk["n"], - e: jwk["e"], - }); - expect(parsed.keys[0]).not.toHaveProperty("d"); - expect(parsed.keys[0]).not.toHaveProperty("p"); - expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); - }); + it.effect("publishes the public form of every signing key and omits the oct fallback", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* resolveJwksEffect( + config, + tempRoot.current, + "a".repeat(32), + testProjectEnvValues, + ); + const parsed = decodeJson(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; + + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ + kty: "RSA", + kid: "test-rsa-kid", + n: jwk["n"], + e: jwk["e"], + }); + expect(parsed.keys[0]).not.toHaveProperty("d"); + expect(parsed.keys[0]).not.toHaveProperty("p"); + expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); + }), + ); // Go decodes `auth.signing_keys_path` directly into `[]JWK`, // so a configured key's `use`/`key_ops`/`ext` metadata must round-trip into the published JWKS // via `ToPublicJWK`, which keeps `use`/`ext` verbatim and // filters `key_ops` down to `"verify"` entries only (never dropping the other two fields). - it("preserves a configured signing key's use/ext and filters key_ops to verify-only", async () => { - const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; - writeSigningKeys(tempRoot.current, [jwk]); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; - - expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); - }); + it.effect("preserves a configured signing key's use/ext and filters key_ops to verify-only", () => + Effect.gen(function* () { + const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; + yield* writeSigningKeys(tempRoot.current, [jwk]); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* resolveJwksEffect( + config, + tempRoot.current, + "a".repeat(32), + testProjectEnvValues, + ); + const parsed = decodeJson(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; + + expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); + }), + ); // Go quirk this reproduces: `a.SigningKeysPath` is resolved to an absolute path // unconditionally, but the FILE is only read into @@ -4622,181 +5048,276 @@ describe("legacyResolveLocalJwks", () => { // unconditional `NewConfig()` default (the single ES256 key) rather than becoming empty. // The oct fallback is still skipped (`len(a.SigningKeysPath) == 0` is false), so the // default ES256 key is the ONLY entry — neither the file's keys nor the oct key appear. - it("falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - const config = baseConfig({ - auth: { enabled: false, signing_keys_path: "signing_keys.json" }, - }); - const jwks = await legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)); - expect(JSON.parse(jwks)).toEqual({ - keys: [ - { - kty: "EC", - kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", - use: "sig", - key_ops: ["verify"], - alg: "ES256", - ext: true, - crv: "P-256", - x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", - y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", - }, - ], - }); - }); - - it("throws a Go-worded error when the signing keys file does not exist", async () => { - const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); - await expect(legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to read signing keys: ", - ); - }); + it.effect( + "falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + const config = baseConfig({ + auth: { enabled: false, signing_keys_path: "signing_keys.json" }, + }); + const jwks = yield* resolveJwksEffect( + config, + tempRoot.current, + "a".repeat(32), + testProjectEnvValues, + ); + expect(decodeJson(jwks)).toEqual({ + keys: [ + { + kty: "EC", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + use: "sig", + key_ops: ["verify"], + alg: "ES256", + ext: true, + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + }, + ], + }); + }), + ); - it("throws a Go-worded error when the signing keys file is malformed JSON", async () => { - const supabaseDir = join(tempRoot.current, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect(legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( - "failed to decode signing keys: ", - ); - }); + it.effect("throws a Go-worded error when the signing keys file does not exist", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, tempRoot.current, "a".repeat(32), testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to read signing keys: "); + }), + ); + + it.effect("throws a Go-worded error when the signing keys file is malformed JSON", () => + Effect.gen(function* () { + const supabaseDir = join(tempRoot.current, "supabase"); + yield* makeDirectoryEffect(supabaseDir); + yield* writeFileEffect(join(supabaseDir, "signing_keys.json"), "not valid json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, tempRoot.current, "a".repeat(32), testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to decode signing keys: "); + }), + ); describe("auth.third_party", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("rejects an enabled third-party provider missing its required field", async () => { - const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); - await expect(legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Invalid config: auth.third_party.firebase is enabled but without a project_id.", - ); - }); + it.effect("rejects an enabled third-party provider missing its required field", () => + Effect.gen(function* () { + const config = baseConfig({ auth: { third_party: { firebase: { enabled: true } } } }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, WORKDIR, "a".repeat(32), testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config: auth.third_party.firebase is enabled but without a project_id.", + ); + }), + ); - it("rejects more than one enabled third-party provider", async () => { - const config = baseConfig({ - auth: { - third_party: { - firebase: { enabled: true, project_id: "my-project" }, - workos: { enabled: true, issuer_url: "https://issuer.example" }, + it.effect("rejects more than one enabled third-party provider", () => + Effect.gen(function* () { + const config = baseConfig({ + auth: { + third_party: { + firebase: { enabled: true, project_id: "my-project" }, + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, }, - }, - }); - await expect(legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "Invalid config: Only one third_party provider allowed to be enabled at a time.", - ); - }); + }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, WORKDIR, "a".repeat(32), testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + "Invalid config: Only one third_party provider allowed to be enabled at a time.", + ); + }), + ); - it("does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", async () => { - // `Auth.ThirdParty.validate()` (the "at most one enabled" check above) only runs - // inside `Config.Validate`'s `if Auth.Enabled` block — `ResolveJWKS`/`IssuerURL()` is called - // unconditionally and never validates, it just picks the first enabled provider by fixed - // priority (firebase, auth0, aws_cognito, clerk, workos) and resolves its remote JWKS. - const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; - const issuerUrl = "https://securetoken.google.com/my-project"; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === `${issuerUrl}/.well-known/openid-configuration`) { - return new Response(JSON.stringify({ jwks_uri: `${issuerUrl}/jwks.json` }), { - status: 200, - headers: { "content-type": "application/json" }, + it.effect( + "does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", + () => + Effect.gen(function* () { + // `Auth.ThirdParty.validate()` (the "at most one enabled" check above) only runs + // inside `Config.Validate`'s `if Auth.Enabled` block — `ResolveJWKS`/`IssuerURL()` is called + // unconditionally and never validates, it just picks the first enabled provider by fixed + // priority (firebase, auth0, aws_cognito, clerk, workos) and resolves its remote JWKS. + const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; + const issuerUrl = "https://securetoken.google.com/my-project"; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url === `${issuerUrl}/.well-known/openid-configuration`) { + return Promise.resolve( + new Response(encodeJson({ jwks_uri: `${issuerUrl}/jwks.json` }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === `${issuerUrl}/jwks.json`) { + return Promise.resolve( + new Response(encodeJson({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch: ${url}`)); }); - } - if (url === `${issuerUrl}/jwks.json`) { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, + const config = baseConfig({ + auth: { + enabled: false, + third_party: { + firebase: { enabled: true, project_id: "my-project" }, + workos: { enabled: true, issuer_url: "https://issuer.example" }, + }, + }, }); - } - throw new Error(`unexpected fetch: ${url}`); - }); - const config = baseConfig({ - auth: { - enabled: false, - third_party: { - firebase: { enabled: true, project_id: "my-project" }, - workos: { enabled: true, issuer_url: "https://issuer.example" }, - }, - }, - }); - const jwksJson = await legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32)); - const jwks = JSON.parse(jwksJson) as { keys: ReadonlyArray<{ kid?: string }> }; - expect(jwks.keys.some((key) => key.kid === "firebase-key")).toBe(true); - fetchMock.mockRestore(); - }); + const jwksJson = yield* resolveJwksEffect( + config, + WORKDIR, + "a".repeat(32), + testProjectEnvValues, + ); + const jwks = decodeJson(jwksJson) as { keys: ReadonlyArray<{ kid?: string }> }; + expect(jwks.keys.some((key) => key.kid === "firebase-key")).toBe(true); + fetchMock.mockRestore(); + }), + ); // `ResolveJWKS` only attempts the remote fetch when `issuerURL != ""`; // workos's own `issuerURL()` is a raw field read // with no validation, so an enabled-but-unconfigured workos provider // with `auth.enabled = false` resolves an empty issuer URL that Go tolerates by skipping the // fetch entirely, rather than attempting (and failing) a fetch against an empty URL. - it('does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url, matching Go\'s issuerURL != "" check', async () => { - const fetchMock = vi.spyOn(globalThis, "fetch"); - const config = baseConfig({ - auth: { - enabled: false, - third_party: { workos: { enabled: true, issuer_url: "" } }, - }, - }); - - const jwksJson = await legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32)); - const jwks = JSON.parse(jwksJson) as { keys: ReadonlyArray<unknown> }; + it.effect( + 'does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url, matching Go\'s issuerURL != "" check', + () => + Effect.gen(function* () { + const fetchMock = vi.spyOn(globalThis, "fetch"); + const config = baseConfig({ + auth: { + enabled: false, + third_party: { workos: { enabled: true, issuer_url: "" } }, + }, + }); - expect(fetchMock).not.toHaveBeenCalled(); - expect(jwks.keys.length).toBeGreaterThan(0); - fetchMock.mockRestore(); - }); + const jwksJson = yield* resolveJwksEffect( + config, + WORKDIR, + "a".repeat(32), + testProjectEnvValues, + ); + const jwks = decodeJson(jwksJson) as { keys: ReadonlyArray<unknown> }; - it("fetches and includes the remote JWKS for an enabled third-party provider", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://issuer.example/.well-known/openid-configuration") { - return new Response(JSON.stringify({ jwks_uri: "https://issuer.example/jwks.json" }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - if (url === "https://issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(jwks.keys.length).toBeGreaterThan(0); + fetchMock.mockRestore(); + }), + ); - const config = baseConfig({ - auth: { third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } } }, - }); - const jwks = await legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32)); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; + it.effect("fetches and includes the remote JWKS for an enabled third-party provider", () => + Effect.gen(function* () { + const remoteKeys = [ + { + kty: "RSA", + kid: "remote-key", + n: "abc", + e: "AQAB", + x5c: ["certificate-chain-entry"], + custom_extension: "preserve-me", + }, + ]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://issuer.example/.well-known/openid-configuration") { + return Promise.resolve( + new Response(encodeJson({ jwks_uri: "https://issuer.example/jwks.json" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === "https://issuer.example/jwks.json") { + return Promise.resolve( + new Response(encodeJson({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); - expect(parsed.keys).toEqual( - expect.arrayContaining([expect.objectContaining({ kid: "remote-key" })]), - ); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }); + const jwks = yield* resolveJwksEffect( + config, + WORKDIR, + "a".repeat(32), + testProjectEnvValues, + ); + const parsed = decodeJson(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; + + expect(parsed.keys).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kid: "remote-key", + x5c: ["certificate-chain-entry"], + custom_extension: "preserve-me", + }), + ]), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }), + ); // The key divergence from `shared/functions/serve.ts`'s own (unrelated) // `finalizeAuthArtifacts`: `start` treats a remote-JWKS fetch failure as a hard, // command-failing error — `legacyResolveLocalJwks` // must propagate it too, not swallow it and continue with zero remote keys. - it("fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", async () => { - vi.spyOn(globalThis, "fetch").mockImplementation(async () => { - throw new Error("oidc discovery failed"); - }); + it.effect( + "fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", + () => + Effect.gen(function* () { + vi.spyOn(globalThis, "fetch").mockImplementation(() => + Promise.reject(new Error("oidc discovery failed")), + ); - const config = baseConfig({ - auth: { third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } } }, - }); - await expect(legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - "oidc discovery failed", - ); - }); + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://issuer.example" } }, + }, + }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, WORKDIR, "a".repeat(32), testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("oidc discovery failed"); + }), + ); }); describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { @@ -4812,177 +5333,226 @@ describe("legacyResolveLocalJwks", () => { "SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", "SUPABASE_AUTH_ENABLED", ]) { - delete process.env[name]; + stubEnv(name, undefined); } }); - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const jwks = await legacyResolveLocalJwks( - config, - tempRoot.current, - "a".repeat(32), - undefined, - new Set(["auth.signing_keys_path"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; - expect(parsed.keys).toHaveLength(1); - expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); - }); - - it("still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", async () => { - writeSigningKeys(tempRoot.current, [generateRsaJwk()]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - await expect( - legacyResolveLocalJwks(config, tempRoot.current, "a".repeat(32)), - ).rejects.toThrow("failed to read signing keys: "); - }); + it.effect( + "prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "missing-file.json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const jwks = yield* resolveJwksEffect( + config, + tempRoot.current, + "a".repeat(32), + testProjectEnvValues, + new Set(["auth.signing_keys_path"]), + ); + const parsed = decodeJson(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; + expect(parsed.keys).toHaveLength(1); + expect(parsed.keys[0]).toMatchObject({ kty: "RSA", kid: "test-rsa-kid" }); + }), + ); - it("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", async () => { - const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; - const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - if (url === "https://remote-issuer.example/.well-known/openid-configuration") { - return new Response( - JSON.stringify({ jwks_uri: "https://remote-issuer.example/jwks.json" }), - { status: 200, headers: { "content-type": "application/json" } }, + it.effect( + "still rejects a missing SUPABASE_AUTH_SIGNING_KEYS_PATH override when no remote block matched", + () => + Effect.gen(function* () { + yield* writeSigningKeys(tempRoot.current, [generateRsaJwk()]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "missing-file.json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, tempRoot.current, "a".repeat(32), testProjectEnvValues), ); - } - if (url === "https://remote-issuer.example/jwks.json") { - return new Response(JSON.stringify({ keys: remoteKeys }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unexpected fetch url: ${url}`); - }); - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED"] = "false"; - process.env["SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL"] = - "https://env-should-not-win.test"; - const config = baseConfig({ - auth: { - third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, - }, - }); - const jwks = await legacyResolveLocalJwks( - config, - WORKDIR, - "a".repeat(32), - undefined, - new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), - ); - const parsed = JSON.parse(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; - expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); - fetchMock.mockRestore(); - }); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to read signing keys: "); + }), + ); - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { - // Regression (review: PRRT_kwDOErm0O86W30n6): this function recomputes `authEnabled` - // itself (see its own doc comment) to gate `resolveThirdPartyIssuerUrl`'s throwing validate - // path — before this fix, the ungated `legacyEnvOverrideBool` call still decoded a - // conflicting env var unconditionally, so a malformed value the remote block should have - // made irrelevant failed the shadow's PG15+ one-shot auth-migration job outright. - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect( - legacyResolveLocalJwks( + it.effect("prefers a remote-set auth.third_party.workos.* over conflicting env overrides", () => + Effect.gen(function* () { + const remoteKeys = [{ kty: "RSA", kid: "remote-key", n: "abc", e: "AQAB" }]; + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://remote-issuer.example/.well-known/openid-configuration") { + return Promise.resolve( + new Response(encodeJson({ jwks_uri: "https://remote-issuer.example/jwks.json" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + if (url === "https://remote-issuer.example/jwks.json") { + return Promise.resolve( + new Response(encodeJson({ keys: remoteKeys }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + } + return Promise.reject(new Error(`unexpected fetch url: ${url}`)); + }); + stubEnv("SUPABASE_AUTH_THIRD_PARTY_WORKOS_ENABLED", "false"); + stubEnv("SUPABASE_AUTH_THIRD_PARTY_WORKOS_ISSUER_URL", "https://env-should-not-win.test"); + const config = baseConfig({ + auth: { + third_party: { workos: { enabled: true, issuer_url: "https://remote-issuer.example" } }, + }, + }); + const jwks = yield* resolveJwksEffect( config, WORKDIR, "a".repeat(32), - undefined, - new Set(["auth.enabled"]), - ), - ).resolves.toEqual(expect.any(String)); - }); + testProjectEnvValues, + new Set(["auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url"]), + ); + const parsed = decodeJson(jwks) as { keys: ReadonlyArray<Record<string, unknown>> }; + expect(parsed.keys.some((key) => key["kid"] === "remote-key")).toBe(true); + fetchMock.mockRestore(); + }), + ); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", async () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - await expect(legacyResolveLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', - ); - }); + it.effect( + "suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", + () => + Effect.gen(function* () { + // Regression (review: PRRT_kwDOErm0O86W30n6): this function recomputes `authEnabled` + // itself (see its own doc comment) to gate `resolveThirdPartyIssuerUrl`'s throwing validate + // path — before this fix, the ungated `legacyEnvOverrideBool` call still decoded a + // conflicting env var unconditionally, so a malformed value the remote block should have + // made irrelevant failed the shadow's PG15+ one-shot auth-migration job outright. + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); + const config = baseConfig({ auth: { enabled: false } }); + const value = yield* resolveJwksEffect( + config, + WORKDIR, + "a".repeat(32), + testProjectEnvValues, + new Set(["auth.enabled"]), + ); + expect(value).toEqual(expect.any(String)); + }), + ); + + it.effect("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); + const config = baseConfig({ auth: { enabled: false } }); + const exit = yield* Effect.exit( + resolveJwksEffect(config, WORKDIR, "a".repeat(32), testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }), + ); }); }); describe("legacyResolveAuthExternalUrl — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { afterEach(() => { - delete process.env["SUPABASE_AUTH_EXTERNAL_URL"]; + stubEnv("SUPABASE_AUTH_EXTERNAL_URL", undefined); }); it("prefers a remote-set auth.external_url over a conflicting SUPABASE_AUTH_EXTERNAL_URL", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-should-not-win.test"; + stubEnv("SUPABASE_AUTH_EXTERNAL_URL", "https://env-should-not-win.test"); const document = { auth: { external_url: "https://remote.test" } }; - expect(legacyResolveAuthExternalUrl(document, undefined, new Set(["auth.external_url"]))).toBe( - "https://remote.test", - ); + expect( + legacyResolveAuthExternalUrl(document, testProjectEnvValues, new Set(["auth.external_url"])), + ).toBe("https://remote.test"); }); it("still applies SUPABASE_AUTH_EXTERNAL_URL when no remote block matched", () => { - process.env["SUPABASE_AUTH_EXTERNAL_URL"] = "https://env-wins.test"; + stubEnv("SUPABASE_AUTH_EXTERNAL_URL", "https://env-wins.test"); const document = { auth: { external_url: "https://configured.test" } }; - expect(legacyResolveAuthExternalUrl(document, undefined)).toBe("https://env-wins.test"); + expect(legacyResolveAuthExternalUrl(document, testProjectEnvValues)).toBe( + "https://env-wins.test", + ); }); }); describe("legacyResolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { const tempRoot = useLegacyTempWorkdir("supabase-configured-signing-keys-test-"); + const resolveConfiguredSigningKeysEffect = ( + ...args: Parameters<typeof legacyResolveConfiguredSigningKeys> + ) => legacyResolveConfiguredSigningKeys(...args).pipe(Effect.provide(BunServices.layer)); afterEach(() => { - delete process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"]; - delete process.env["SUPABASE_AUTH_ENABLED"]; - }); - - it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - const keys = legacyResolveConfiguredSigningKeys( - config, - tempRoot.current, - undefined, - new Set(["auth.signing_keys_path"]), - ); - expect(keys).toHaveLength(1); - expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", undefined); + stubEnv("SUPABASE_AUTH_ENABLED", undefined); }); - it("still reads the env-overridden path when no remote block matched", () => { - const jwk = generateRsaJwk(); - writeSigningKeys(tempRoot.current, [jwk]); - process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; - const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); - expect(() => legacyResolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - "failed to read signing keys: ", - ); - }); + it.effect( + "prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", + () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "missing-file.json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const keys = yield* resolveConfiguredSigningKeysEffect( + config, + tempRoot.current, + testProjectEnvValues, + new Set(["auth.signing_keys_path"]), + ); + expect(keys).toHaveLength(1); + expect(keys?.[0]).toMatchObject({ kid: "test-rsa-kid" }); + }), + ); - it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W30n6): this function's own `authEnabled` recompute - // (see its doc comment) used to be ungated, so a malformed override the remote block should - // have made irrelevant aborted the anon/service_role asymmetric-signing path outright. - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => - legacyResolveConfiguredSigningKeys( - config, - tempRoot.current, - undefined, - new Set(["auth.enabled"]), - ), - ).not.toThrow(); - }); + it.effect("still reads the env-overridden path when no remote block matched", () => + Effect.gen(function* () { + const jwk = generateRsaJwk(); + yield* writeSigningKeys(tempRoot.current, [jwk]); + stubEnv("SUPABASE_AUTH_SIGNING_KEYS_PATH", "missing-file.json"); + const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); + const exit = yield* Effect.exit( + resolveConfiguredSigningKeysEffect(config, tempRoot.current, testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain("failed to read signing keys: "); + }), + ); + + it.effect( + "suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", + () => + Effect.gen(function* () { + // Regression (review: PRRT_kwDOErm0O86W30n6): this function's own `authEnabled` recompute + // (see its doc comment) used to be ungated, so a malformed override the remote block should + // have made irrelevant aborted the anon/service_role asymmetric-signing path outright. + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); + const config = baseConfig({ auth: { enabled: false } }); + yield* resolveConfiguredSigningKeysEffect( + config, + tempRoot.current, + testProjectEnvValues, + new Set(["auth.enabled"]), + ); + }), + ); - it("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => { - process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; - const config = baseConfig({ auth: { enabled: false } }); - expect(() => legacyResolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', - ); - }); + it.effect("still rejects a malformed SUPABASE_AUTH_ENABLED when no remote block matched", () => + Effect.gen(function* () { + stubEnv("SUPABASE_AUTH_ENABLED", "not-a-bool"); + const config = baseConfig({ auth: { enabled: false } }); + const exit = yield* Effect.exit( + resolveConfiguredSigningKeysEffect(config, tempRoot.current, testProjectEnvValues), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) + expect(String(exit.cause)).toContain( + 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + ); + }), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-local-gateway-http-client.ts b/apps/cli/src/legacy/shared/legacy-local-gateway-http-client.ts new file mode 100644 index 0000000000..dba76725b6 --- /dev/null +++ b/apps/cli/src/legacy/shared/legacy-local-gateway-http-client.ts @@ -0,0 +1,45 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; +import { Context, Effect, Layer } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +/** + * Transport boundary for requests to a local Supabase gateway. + * + * Local gateway traffic must not inherit the process' proxy environment. The + * production implementation uses Node's direct HTTP transport and scopes an + * optional local Kong CA to the operation. Tests provide their captured + * HttpClient instead, keeping route-state mocks authoritative while exercising + * the same boundary as production. + */ +export interface LegacyLocalGatewayHttpClientShape { + readonly use: <A, E, R>( + localKongCa: string | undefined, + effect: Effect.Effect<A, E, R | HttpClient.HttpClient>, + ) => Effect.Effect<A, E, R>; +} + +export class LegacyLocalGatewayHttpClient extends Context.Service< + LegacyLocalGatewayHttpClient, + LegacyLocalGatewayHttpClientShape +>()("supabase/legacy/LocalGatewayHttpClient") {} + +function nodeHttpLayer(localKongCa: string | undefined): Layer.Layer<HttpClient.HttpClient> { + return NodeHttpClient.layerNodeHttpNoAgent.pipe( + Layer.provide( + NodeHttpClient.layerAgentOptions(localKongCa === undefined ? undefined : { ca: localKongCa }), + ), + ); +} + +/** Production transport: direct node:http/https with an optional Kong CA. */ +export const legacyLocalGatewayHttpClientLayer = Layer.succeed(LegacyLocalGatewayHttpClient, { + use: (localKongCa, effect) => effect.pipe(Effect.provide(nodeHttpLayer(localKongCa))), +}); + +/** Test transport: preserve the caller's captured/mock HttpClient layer. */ +export const legacyLocalGatewayHttpClientTestLayer = ( + httpClientLayer: Layer.Layer<HttpClient.HttpClient>, +): Layer.Layer<LegacyLocalGatewayHttpClient> => + Layer.succeed(LegacyLocalGatewayHttpClient, { + use: (_localKongCa, effect) => effect.pipe(Effect.provide(httpClientLayer)), + }); diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 05d59a84cb..4a7c72927a 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -1,17 +1,49 @@ import { + ENV_CAPTURE_REGEX, loadProjectConfig, loadProjectEnvironment, ProjectConfigSchema, type LoadedProjectConfig, type ProjectConfig, } from "@supabase/config"; -import { Effect, FileSystem, Path, Schema } from "effect"; +import { Crypto, Effect, FileSystem, Option, Path, Schema } from "effect"; +import { parse as parseToml, type TomlTable, type TomlValue } from "smol-toml"; -import { LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY } from "./legacy-bitbucket-pipeline.ts"; import { legacyResolveLocalProjectId, legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; +import { LegacyViperEnv, legacyViperEnvEntries } from "../../shared/legacy/legacy-viper-env.ts"; import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; +const LEGACY_PROJECT_ENV_KEYS = [ + "BITBUCKET_CLONE_DIR", + "KONG_NGINX_WORKER_PROCESSES", + "VECTOR_ENABLED", + "VECTOR_BUCKET_PROVIDER", + "VECTOR_STORE_MIGRATIONS_ENABLED", + "VECTOR_DATABASE_URL", +] as const; + +function isTomlTable(value: TomlValue): value is TomlTable { + return ( + typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date) + ); +} + +function collectConfigEnvReferences(value: TomlValue, names: Set<string>): void { + if (typeof value === "string") { + const name = ENV_CAPTURE_REGEX.exec(value)?.[1]; + if (name !== undefined) names.add(name); + return; + } + if (Array.isArray(value)) { + for (const nested of value) collectConfigEnvReferences(nested, names); + return; + } + if (isTomlTable(value)) { + for (const nested of Object.values(value)) collectConfigEnvReferences(nested, names); + } +} + /** * The config-load/env/project-id resolution `stop` (its non-`--all`/non-`--project-id` branch) * and `status` (unconditionally) both duplicated verbatim before this hoist. @@ -62,8 +94,67 @@ export const legacyLoadLocalProjectContext = <E>( // when that default is itself empty. `db start`/`db reset`/`start`/`stop`/`status` never // pass this, so it defaults to `undefined` — no remote merge, unchanged from before. projectRef?: string, -): Effect.Effect<LegacyLocalProjectContext, E, FileSystem.FileSystem | Path.Path> => +): Effect.Effect< + LegacyLocalProjectContext, + E, + FileSystem.FileSystem | Path.Path | Crypto.Crypto | LegacyViperEnv +> => Effect.gen(function* () { + const viperEnv = yield* LegacyViperEnv; + const environmentEntries = yield* Effect.forEach(LEGACY_PROJECT_ENV_KEYS, (name) => + viperEnv.get(name).pipe(Effect.map((value) => [name, value] as const)), + ).pipe( + Effect.mapError((cause) => + mapConfigLoadError(`failed to read environment: ${String(cause)}`), + ), + ); + // Viper's AutomaticEnv exposes every ambient SUPABASE_* key, including + // port overrides needed when no config.toml exists. Keep non-SUPABASE + // compatibility entries explicit so project dotenv values remain scoped. + const ambientSupabaseEntries = yield* legacyViperEnvEntries("SUPABASE").pipe( + Effect.mapError((cause) => + mapConfigLoadError(`failed to read environment: ${String(cause)}`), + ), + ); + const environment = { + ...ambientSupabaseEntries, + ...Object.fromEntries( + environmentEntries.flatMap(([key, value]) => + Option.isSome(value) ? [[key, value.value]] : [], + ), + ), + }; + // `loadProjectConfig` resolves every `env(NAME)` reference against the + // supplied project environment. Keep that environment explicit while + // discovering the referenced shell keys from the TOML itself; the ambient + // SUPABASE_* enumeration above does not cover arbitrary config references. + const configEnvNames = yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(workdir, "supabase", "config.toml"); + if (!(yield* fs.exists(configPath))) return []; + const contents = yield* fs.readFileString(configPath); + const names = new Set<string>(); + const document = yield* Effect.try({ + try: () => parseToml(contents), + catch: (cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`), + }); + collectConfigEnvReferences(document, names); + return Array.from(names); + }).pipe( + Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), + ); + const configEnvironmentEntries = yield* Effect.forEach(configEnvNames, (name) => + viperEnv.get(name).pipe( + Effect.map((value): readonly [string, Option.Option<string>] => [name, value]), + Effect.mapError((cause) => + mapConfigLoadError(`failed to read environment: ${String(cause)}`), + ), + ), + ); + for (const [name, value] of configEnvironmentEntries) { + if (Option.isSome(value)) environment[name] = value.value; + } // `search: false`: `workdir` already IS the fully-resolved chdir target (`legacy-cli-config. // layer.ts`'s `resolveWorkdir` mirrors `ChangeWorkDir`'s explicit-exact-vs-default-searched // resolution), so letting `@supabase/config`'s @@ -73,7 +164,6 @@ export const legacyLoadLocalProjectContext = <E>( // defaulted) workdir (`NewPathBuilder`). const projectEnv = yield* loadProjectEnvironment({ cwd: workdir, - baseEnv: process.env, search: false, // `loadDefaultEnv` omits `.env.local` // from its candidate list whenever `SUPABASE_ENV=test` — a malformed or intentionally @@ -81,7 +171,7 @@ export const legacyLoadLocalProjectContext = <E>( // here either. `legacyResolveProjectEnvironmentValues` below already applies this same gate // for the project-root pass; this mirrors it for the `supabase/`-dir pass // `loadProjectEnvironment` itself performs. - skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + skipEnvLocal: (environment["SUPABASE_ENV"] ?? "development") === "test", }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), ); @@ -95,32 +185,11 @@ export const legacyLoadLocalProjectContext = <E>( // line. `workdir` is passed through so dotenv files under `<workdir>/supabase`/`workdir` are // still discovered even when `projectEnv` is `null` (no config.toml there) — Go's own // `loadNestedEnv` runs unconditionally, before `config.toml` is ever opened. - const projectEnvValues = yield* Effect.try({ - try: () => legacyResolveProjectEnvironmentValues(projectEnv, workdir), - catch: (cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`), - }); - - // `godotenv.Load` (`loadEnvIfExists`, called by `loadNestedEnv` above this same - // config-load pass) installs every parsed dotenv key into - // the process's OWN environment via `os.Setenv` — never overriding an already-set key — - // so it's visible to every subsequent call in THIS process that reads `process.env` at - // CALL time, not just to config decoding. `BITBUCKET_CLONE_DIR` is the - // one key this applies to today: `os.Getenv("BITBUCKET_CLONE_DIR")` read - // lives inside `DockerStart`, a regular - // function invoked during the command's own `Run()`, well after config load has already - // installed dotenv keys into the process env — not in a - // package-level `var` initializer evaluated before that ever runs (see - // {@link LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY}'s own doc comment; review: - // PRRT_kwDOErm0O86VmHkm) — so a value set ONLY in a project `.env` file genuinely reaches - // it too. Deliberately permanent (unlike `legacyApplyProjectEnv`'s own narrower, - // explicitly-scoped opt-in around a single command's container work) — matching the - // established non-reverting `os.Setenv`, which persists for that single-command process's entire - // lifetime. - for (const [key, value] of Object.entries(projectEnvValues)) { - if (key === LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY && process.env[key] === undefined) { - process.env[key] = value; - } - } + const projectEnvValues = yield* legacyResolveProjectEnvironmentValues( + projectEnv, + workdir, + environment, + ).pipe(Effect.mapError((cause) => mapConfigLoadError(cause.message))); // Deliberately NOT extended to Docker-client keys (`DOCKER_HOST`/`DOCKER_CONTEXT`/ // `DOCKER_CONFIG`/etc, `legacyIsDockerClientEnvKey`), unlike an earlier version of this @@ -178,8 +247,12 @@ export const legacyLoadLocalProjectContext = <E>( }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), ); - const config = loaded?.config ?? Schema.decodeUnknownSync(ProjectConfigSchema)({}); - const hostname = legacyGetHostname(); + const config = + loaded?.config ?? + (yield* Schema.decodeEffect(ProjectConfigSchema)({}).pipe( + Effect.mapError((cause) => mapConfigLoadError(`failed to decode config: ${String(cause)}`)), + )); + const hostname = yield* legacyGetHostname; // `loaded?.appliedRemote !== undefined` means a `[remotes.<ref>]` block matched // `projectRef` above and `loadProjectConfig` merged it over the base document // (`packages/config/src/io.ts`'s `applyRemoteOverride`) — including that block's OWN @@ -193,9 +266,7 @@ export const legacyLoadLocalProjectContext = <E>( // (review: PRRT_kwDOErm0O86XHGDL). const projectId = legacySanitizeProjectId( legacyResolveLocalProjectId( - loaded?.appliedRemote !== undefined - ? undefined - : (projectEnvValues["SUPABASE_PROJECT_ID"] ?? process.env["SUPABASE_PROJECT_ID"]), + loaded?.appliedRemote !== undefined ? undefined : projectEnvValues["SUPABASE_PROJECT_ID"], config.project_id, workdir, projectRef, diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts index a24f80dcd1..6a0ad7db01 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.unit.test.ts @@ -1,10 +1,11 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { afterEach, describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Config, ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; +import { legacyResolveLocalConfigValues } from "./legacy-local-config-values.ts"; import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts"; /** @@ -19,38 +20,81 @@ import { legacyLoadLocalProjectContext } from "./legacy-local-project-context.ts const DOCKER_HOST_KEY = "DOCKER_HOST"; /** - * `BITBUCKET_CLONE_DIR` is installed alongside the Docker-client keys even though it isn't one - * itself — see `LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY`'s doc comment (review: - * PRRT_kwDOErm0O86VmHkm) for why this key, unlike `SUPABASE_SERVICES_HOSTNAME`, must reach - * `process.env` from a project-only dotenv file. + * `BITBUCKET_CLONE_DIR` remains in the resolved project environment map so + * container boundaries can consume it without mutating global process state. */ const BITBUCKET_CLONE_DIR_KEY = "BITBUCKET_CLONE_DIR"; -function writeDotEnv(workdir: string, contents: string): void { - mkdirSync(workdir, { recursive: true }); - writeFileSync(join(workdir, ".env"), contents); +function testLayer(env: Readonly<Record<string, string>> = {}) { + const provider = ConfigProvider.fromEnv({ env, preserveEmptyStrings: true }); + return Layer.mergeAll( + BunServices.layer, + ConfigProvider.layer(provider), + makeLegacyViperEnvLayer(provider), + ); +} + +function writeDotEnv(workdir: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(workdir, { recursive: true }); + yield* fs.writeFileString(path.join(workdir, ".env"), contents); + }); } -function writeConfigToml(workdir: string, contents: string): void { - const supabaseDir = join(workdir, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); - writeFileSync(join(supabaseDir, "config.toml"), contents); +function writeConfigToml(workdir: string, contents: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), contents); + }); } const tempRoot = useLegacyTempWorkdir("supabase-legacy-project-context-"); describe("legacyLoadLocalProjectContext", () => { - const previousDockerHost = process.env[DOCKER_HOST_KEY]; - const previousBitbucketCloneDir = process.env[BITBUCKET_CLONE_DIR_KEY]; - const previousProjectId = process.env["SUPABASE_PROJECT_ID"]; - - afterEach(() => { - if (previousDockerHost === undefined) delete process.env[DOCKER_HOST_KEY]; - else process.env[DOCKER_HOST_KEY] = previousDockerHost; - if (previousBitbucketCloneDir === undefined) delete process.env[BITBUCKET_CLONE_DIR_KEY]; - else process.env[BITBUCKET_CLONE_DIR_KEY] = previousBitbucketCloneDir; - if (previousProjectId === undefined) delete process.env["SUPABASE_PROJECT_ID"]; - else process.env["SUPABASE_PROJECT_ID"] = previousProjectId; + it.effect("resolves nested env references while ignoring TOML comments", () => { + const workdir = tempRoot.current; + const queried: Array<string> = []; + const provider = ConfigProvider.fromEnv({ + env: { COMMENT_ONLY: "9999", NESTED_PORT: "5544" }, + preserveEmptyStrings: true, + }); + const layer = Layer.mergeAll( + BunServices.layer, + ConfigProvider.layer(provider), + Layer.succeed(LegacyViperEnv, { + get: (name) => + Effect.sync(() => { + queried.push(name); + const value = { COMMENT_ONLY: "9999", NESTED_PORT: "5544" }[name]; + return value === undefined ? Option.none() : Option.some(value); + }), + entries: () => Effect.succeed({}), + }), + ); + return Effect.gen(function* () { + yield* writeConfigToml( + workdir, + [ + "# env(COMMENT_ONLY) is documentation, not a config reference", + 'project_id = "nested-project"', + "[api]", + 'port = "env(NESTED_PORT)"', + "", + ].join("\n"), + ); + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ); + expect(context.config.api.port).toBe(5544); + expect(queried).toContain("NESTED_PORT"); + expect(queried).not.toContain("COMMENT_ONLY"); + }).pipe(Effect.provide(layer)); }); it.effect( @@ -61,100 +105,134 @@ describe("legacyLoadLocalProjectContext", () => { // Go's viper override tier before this reads it; letting an unrelated // `SUPABASE_PROJECT_ID` win here would resolve the WRONG project id for the shadow's // own network id/container labels on a linked `db diff`/`db pull`. - process.env["SUPABASE_PROJECT_ID"] = "local"; const ref = "abcdefghijklmnopqrst"; const workdir = tempRoot.current; - writeConfigToml( - workdir, - ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), - ); - - return legacyLoadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( - Effect.map((context) => { - expect(context.loaded?.appliedRemote).toBe("prod"); - expect(context.projectId).toBe(ref); - }), - Effect.provide(BunServices.layer), - ); + return Effect.gen(function* () { + yield* writeConfigToml( + workdir, + ['project_id = "toml-project"', "[remotes.prod]", `project_id = "${ref}"`, ""].join("\n"), + ); + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ref, + ); + expect(context.loaded?.appliedRemote).toBe("prod"); + expect(context.projectId).toBe(ref); + }).pipe(Effect.provide(testLayer({ SUPABASE_PROJECT_ID: "local" }))); }, ); it.effect("still applies SUPABASE_PROJECT_ID when no [remotes.*] block matches the ref", () => { - process.env["SUPABASE_PROJECT_ID"] = "env-project"; const ref = "abcdefghijklmnopqrst"; const workdir = tempRoot.current; - writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + return Effect.gen(function* () { + yield* writeConfigToml(workdir, ['project_id = "toml-project"', ""].join("\n")); + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ref, + ); + expect(context.loaded?.appliedRemote).toBeUndefined(); + expect(context.projectId).toBe("env-project"); + }).pipe(Effect.provide(testLayer({ SUPABASE_PROJECT_ID: "env-project" }))); + }); - return legacyLoadLocalProjectContext(workdir, (message) => new Error(message), ref).pipe( - Effect.map((context) => { - expect(context.loaded?.appliedRemote).toBeUndefined(); - expect(context.projectId).toBe("env-project"); - }), - Effect.provide(BunServices.layer), - ); + it.effect("preserves ambient API and DB port overrides without config.toml", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ); + const values = yield* legacyResolveLocalConfigValues( + context.config, + context.hostname, + workdir, + context.projectEnvValues, + context.loaded?.document, + ); + expect(values.apiPort).toBe(65431); + expect(values.dbPort).toBe(65432); + }).pipe(Effect.provide(testLayer({ SUPABASE_API_PORT: "65431", SUPABASE_DB_PORT: "65432" }))); }); it.effect( "does NOT install a project .env's DOCKER_HOST into process.env, matching Go's Docker client being frozen at binary startup, before godotenv.Load ever runs", () => { - delete process.env[DOCKER_HOST_KEY]; const workdir = tempRoot.current; - writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - - return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[DOCKER_HOST_KEY]).toBeUndefined(); - }), - Effect.provide(BunServices.layer), - ); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ); + const dockerHost = yield* Config.option(Config.string(DOCKER_HOST_KEY)); + expect(Option.isNone(dockerHost)).toBe(true); + }).pipe(Effect.provide(testLayer())); }, ); it.effect( "leaves an already-set shell DOCKER_HOST untouched regardless of a conflicting project .env value", () => { - process.env[DOCKER_HOST_KEY] = "tcp://real-shell-host:2375"; const workdir = tempRoot.current; - writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - - return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[DOCKER_HOST_KEY]).toBe("tcp://real-shell-host:2375"); - }), - Effect.provide(BunServices.layer), - ); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ); + const dockerHost = yield* Config.option(Config.string(DOCKER_HOST_KEY)); + expect(Option.getOrUndefined(dockerHost)).toBe("tcp://real-shell-host:2375"); + }).pipe(Effect.provide(testLayer({ [DOCKER_HOST_KEY]: "tcp://real-shell-host:2375" }))); }, ); - it.effect( - "installs a project .env's BITBUCKET_CLONE_DIR into process.env, matching Go's godotenv.Load preceding DockerStart's os.Getenv read", - () => { - delete process.env[BITBUCKET_CLONE_DIR_KEY]; - const workdir = tempRoot.current; - writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - - return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[BITBUCKET_CLONE_DIR_KEY]).toBe("/opt/atlassian/pipelines/agent/build"); - }), - Effect.provide(BunServices.layer), + it.effect("resolves a project .env's BITBUCKET_CLONE_DIR for container boundaries", () => { + const workdir = tempRoot.current; + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), ); - }, - ); + expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe( + "/opt/atlassian/pipelines/agent/build", + ); + }).pipe(Effect.provide(testLayer())); + }); it.effect( - "never overrides an already-set BITBUCKET_CLONE_DIR, matching godotenv.Load's shell-env-wins semantics", + "preserves an ambient BITBUCKET_CLONE_DIR over a conflicting project dotenv value", () => { - process.env[BITBUCKET_CLONE_DIR_KEY] = "/real-shell-clone-dir"; const workdir = tempRoot.current; - writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); - - return legacyLoadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); - }), - Effect.provide(BunServices.layer), - ); + return Effect.gen(function* () { + yield* writeDotEnv(workdir, `BITBUCKET_CLONE_DIR=/opt/atlassian/pipelines/agent/build\n`); + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ); + expect(context.projectEnvValues[BITBUCKET_CLONE_DIR_KEY]).toBe("/real-shell-clone-dir"); + }).pipe(Effect.provide(testLayer({ BITBUCKET_CLONE_DIR: "/real-shell-clone-dir" }))); }, ); + + it.effect("captures ambient unprefixed start service overrides", () => { + const workdir = tempRoot.current; + const overrides = { + KONG_NGINX_WORKER_PROCESSES: "auto", + VECTOR_ENABLED: "false", + VECTOR_BUCKET_PROVIDER: "custom", + VECTOR_STORE_MIGRATIONS_ENABLED: "false", + VECTOR_DATABASE_URL: "postgresql://vector.example.test/postgres", + }; + return Effect.gen(function* () { + const context = yield* legacyLoadLocalProjectContext( + workdir, + (message) => new Cause.UnknownError(undefined, String(message)), + ); + expect(context.projectEnvValues).toMatchObject(overrides); + }).pipe(Effect.provide(testLayer(overrides))); + }); }); diff --git a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts index abf75d456d..ff4f8f6596 100644 --- a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts @@ -41,12 +41,10 @@ export const legacyLoginApiLayer = Layer.effect( const response = yield* httpClient.execute(request); if (response.status !== 200) { const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new LegacyLoginVerificationError({ - message: `Error status ${response.status}: ${body}`, - statusCode: response.status, - }), - ); + return yield* new LegacyLoginVerificationError({ + message: `Error status ${response.status}: ${body}`, + statusCode: response.status, + }); } const body = yield* response.json; const session: LegacyLoginSessionResponse = { diff --git a/apps/cli/src/legacy/shared/legacy-login-crypto.layer.ts b/apps/cli/src/legacy/shared/legacy-login-crypto.layer.ts index 854626808d..02e80f3fd2 100644 --- a/apps/cli/src/legacy/shared/legacy-login-crypto.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-login-crypto.layer.ts @@ -1,7 +1,7 @@ import { Buffer } from "node:buffer"; import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; import { hostname, userInfo } from "node:os"; -import { Effect, Layer } from "effect"; +import { Clock, Effect, Layer } from "effect"; import { LegacyLoginCrypto, @@ -23,17 +23,19 @@ export const legacyLoginCryptoLayer = Layer.sync(LegacyLoginCrypto, () => new LegacyLoginCryptoError({ message: `cannot generate crypto keys: ${String(cause)}` }), }), generateSessionId: Effect.sync(() => randomUUID()), - defaultTokenName: Effect.sync(() => { - const ts = Math.floor(Date.now() / 1000); - try { - const user = userInfo().username; - const host = hostname(); - if (user && host) return `cli_${user}@${host}_${ts}`; - } catch { - /* fall through to the fallback name (Go's generateTokenNameWithFallback) */ - } - return `cli_${ts}`; - }), + defaultTokenName: Clock.currentTimeMillis.pipe( + Effect.map((currentTimeMillis) => { + const ts = Math.floor(currentTimeMillis / 1000); + try { + const user = userInfo().username; + const host = hostname(); + if (user && host) return `cli_${user}@${host}_${ts}`; + } catch { + /* fall through to the fallback name (Go's generateTokenNameWithFallback) */ + } + return `cli_${ts}`; + }), + ), decryptToken: (ecdh: ECDH, payload: LegacyEncryptedPayload) => Effect.try({ try: () => { diff --git a/apps/cli/src/legacy/shared/legacy-make-dir.unit.test.ts b/apps/cli/src/legacy/shared/legacy-make-dir.unit.test.ts index 62fab1a4bd..28afcc5032 100644 --- a/apps/cli/src/legacy/shared/legacy-make-dir.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-make-dir.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Layer, PlatformError } from "effect"; import { legacyMakeDir } from "./legacy-make-dir.ts"; @@ -83,7 +83,7 @@ describe("legacyMakeDir", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("PermissionDenied"); + expect(Formatter.formatJson(exit.cause)).toContain("PermissionDenied"); } }), ), diff --git a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts index 56c2ad23c9..95aa80d161 100644 --- a/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-management-api-runtime.layer.ts @@ -121,12 +121,14 @@ export function legacyManagementApiRuntimeLayer(subcommand: ReadonlyArray<string // `LegacyOutputFlag`, `Analytics`, `Stdio`, `Tty`, …) and is therefore // already provided via `runCli` / `cliProgramFor` — no change here. // - // The assertion uses `unknown` for E and R so that the assertion ONLY fires - // for missing exposed services; changes to the layer's internal error / - // requirement channels do not perturb this check. cli-e2e parity tests - // surface missing-service runtime panics, but the same class of bug is now - // caught at compile time. - const _serviceCoverageCheck: Layer.Layer<LegacyManagementApiServices, unknown, unknown> = built; + // Preserve the concrete channels inferred from the composed layer while checking + // that every service exposed to handlers is present. This keeps the assertion + // sensitive to missing services without widening the runtime's requirements. + const _serviceCoverageCheck: Layer.Layer< + LegacyManagementApiServices, + Layer.Error<typeof built>, + Layer.Services<typeof built> + > = built; void _serviceCoverageCheck; return built; diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts index 96d1b07de5..0d3cedb478 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.ts @@ -17,6 +17,8 @@ import { legacyApplySeedFiles, type LegacySeedConfig } from "./legacy-seed.ts"; /** Config consumed by `legacyMigrateAndSeed`. */ export interface LegacyMigrateAndSeedConfig { + /** Fully resolved environment for scanner/config overrides; never read from process globals. */ + readonly projectEnv: Readonly<Record<string, string>>; readonly migrationsEnabled: boolean; readonly seed: LegacySeedConfig; /** @@ -97,5 +99,5 @@ export const legacyMigrateAndSeed = ( ); } } - yield* legacyApplySeedFiles(session, fs, path, workdir, config.seed); + yield* legacyApplySeedFiles(session, fs, path, workdir, config.seed, config.projectEnv); }); diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts index 512155a7d2..9703af37e2 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -1,12 +1,10 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Layer, Path } from "effect"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { actionability, ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; @@ -84,17 +82,36 @@ function assertMigrationApplyError(error: unknown): asserts error is LegacyMigra } } -function makeWorkdir(): string { - return mkdtempSync(join(tmpdir(), "legacy-migrate-and-seed-")); -} +const tempRoot = useLegacyTempWorkdir("legacy-migrate-and-seed-"); -function writeFile(workdir: string, relativePath: string, content: string): void { - const fullPath = join(workdir, relativePath); - mkdirSync(join(fullPath, ".."), { recursive: true }); - writeFileSync(fullPath, content); -} +const writeFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + relativePath: string, + content: string, +) => { + const fullPath = path.join(workdir, relativePath); + return fs + .makeDirectory(path.dirname(fullPath), { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(fullPath, content))); +}; + +const withFixture = <A>( + use: ( + workdir: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect<A, Error, FileSystem.FileSystem | Path.Path>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* use(tempRoot.current, fs, path); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie); const baseConfig: LegacyMigrateAndSeedConfig = { + projectEnv: {}, migrationsEnabled: true, seed: { enabled: false, sqlPaths: [] }, experimental: false, @@ -119,37 +136,42 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { it.effect( "applies schema_paths files instead of migrations when experimental is on, pg-delta is off, and version is empty", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table schema_marker ()"); - expect(execs).not.toContain("create table migration_marker ()"); - // `applyMigrationFiles` prints "Applying migration ...", which - // `applySchemaFiles` never does — confirms the migration branch didn't run too. - expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile( + fs, + path, + workdir, + "supabase/schemas/a.sql", + "create table schema_marker ();", + ); + yield* writeFile( + fs, + path, + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table schema_marker ()"); + expect(execs).not.toContain("create table migration_marker ()"); + // `applyMigrationFiles` prints "Applying migration ...", which + // `applySchemaFiles` never does — confirms the migration branch didn't run too. + expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); + }), ); }, ); @@ -157,134 +179,158 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { it.effect( "falls back to migration files when pg-delta is enabled, even with experimental on and an empty version", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - pgDeltaEnabled: true, - schemaPaths: ["supabase/schemas/a.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile( + fs, + path, + workdir, + "supabase/schemas/a.sql", + "create table schema_marker ();", + ); + yield* writeFile( + fs, + path, + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: true, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + }), ); }, ); it.effect("falls back to migration files when experimental is off", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: false, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile( + fs, + path, + workdir, + "supabase/schemas/a.sql", + "create table schema_marker ();", + ); + yield* writeFile( + fs, + path, + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: false, + pgDeltaEnabled: false, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + }), ); }); it.effect( "falls back to migration files when a concrete version is passed, even with experimental on", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile( - workdir, - "supabase/migrations/20240101000000_x.sql", - "create table migration_marker ();", - ); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "20240101000000", - { - ...baseConfig, - experimental: true, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table migration_marker ()"); - expect(execs).not.toContain("create table schema_marker ()"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile( + fs, + path, + workdir, + "supabase/schemas/a.sql", + "create table schema_marker ();", + ); + yield* writeFile( + fs, + path, + workdir, + "supabase/migrations/20240101000000_x.sql", + "create table migration_marker ();", + ); + yield* run( + workdir, + "20240101000000", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["supabase/schemas/a.sql"], + }, + session, + out, + ); + expect(execs).toContain("create table migration_marker ()"); + expect(execs).not.toContain("create table schema_marker ()"); + }), ); }, ); it.effect("still seeds after the declarative-schema branch runs", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/a.sql", "create table schema_marker ();"); - writeFile(workdir, "supabase/seed.sql", "insert into schema_marker default values;"); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - pgDeltaEnabled: false, - schemaPaths: ["supabase/schemas/a.sql"], - // Both `schemaPaths` and `LegacySeedConfig.sqlPaths` arrive already - // `supabase/`-prefixed by their real caller (`legacy-db-config.toml-read.ts`) — see - // its own doc comment. Neither field does its own path-shape work anymore. - seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("create table schema_marker ()"); - expect(execs).toContain("insert into schema_marker default values"); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile( + fs, + path, + workdir, + "supabase/schemas/a.sql", + "create table schema_marker ();", + ); + yield* writeFile( + fs, + path, + workdir, + "supabase/seed.sql", + "insert into schema_marker default values;", + ); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + pgDeltaEnabled: false, + schemaPaths: ["supabase/schemas/a.sql"], + // Both `schemaPaths` and `LegacySeedConfig.sqlPaths` arrive already + // `supabase/`-prefixed by their real caller (`legacy-db-config.toml-read.ts`) — see + // its own doc comment. Neither field does its own path-shape work anymore. + seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, + }, + session, + out, + ); + expect(execs).toContain("create table schema_marker ()"); + expect(execs).toContain("insert into schema_marker default values"); + }), ); }); @@ -292,60 +338,64 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { it.effect( "expands a directory schema_paths entry to its .sql files, recursively, in declared order", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/schemas/z_function.sql", "select 1;"); - writeFile(workdir, "supabase/schemas/tables/a_table.sql", "select 2;"); - writeFile(workdir, "supabase/schemas/tables/nested/b_table.sql", "select 3;"); - writeFile(workdir, "supabase/schemas/tables/readme.md", "ignored"); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/schemas/z_function.sql", "supabase/schemas/tables"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - const order = execs.filter((sql) => sql.startsWith("select ")); - expect(order).toEqual(["select 1", "select 2", "select 3"]); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, workdir, "supabase/schemas/z_function.sql", "select 1;"); + yield* writeFile(fs, path, workdir, "supabase/schemas/tables/a_table.sql", "select 2;"); + yield* writeFile( + fs, + path, + workdir, + "supabase/schemas/tables/nested/b_table.sql", + "select 3;", + ); + yield* writeFile(fs, path, workdir, "supabase/schemas/tables/readme.md", "ignored"); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas/z_function.sql", "supabase/schemas/tables"], + }, + session, + out, + ); + const order = execs.filter((sql) => sql.startsWith("select ")); + expect(order).toEqual(["select 1", "select 2", "select 3"]); + }), ); }, ); it.effect("deduplicates an explicit file also matched by a directory/glob pattern", () => { - const workdir = makeWorkdir(); - writeFile(workdir, "supabase/database/a.sql", "select 10;"); - writeFile(workdir, "supabase/database/b.sql", "select 20;"); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/database/a.sql", "supabase/database", "supabase/database/*.sql"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - const order = execs.filter((sql) => sql.startsWith("select ")); - // Each file applied exactly once, in sorted order — not once per matching pattern. - expect(order).toEqual(["select 10", "select 20"]); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, workdir, "supabase/database/a.sql", "select 10;"); + yield* writeFile(fs, path, workdir, "supabase/database/b.sql", "select 20;"); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: [ + "supabase/database/a.sql", + "supabase/database", + "supabase/database/*.sql", + ], + }, + session, + out, + ); + const order = execs.filter((sql) => sql.startsWith("select ")); + // Each file applied exactly once, in sorted order — not once per matching pattern. + expect(order).toEqual(["select 10", "select 20"]); + }), ); }); @@ -355,35 +405,31 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { // `walkMatchedDir` returns `failed to walk matched directory: %w` on a read // error; `applySchemaFiles` propagates it when nothing else matched either. Mode // 000 makes `stat` (parent-directory lookup) succeed but `readdir` fail with EACCES. - const workdir = makeWorkdir(); - const lockedDir = join(workdir, "supabase", "schemas", "locked"); - mkdirSync(lockedDir, { recursive: true }); - writeFileSync(join(lockedDir, "b.sql"), "select 1;"); - chmodSync(lockedDir, 0o000); const { session } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/schemas/locked"], - }, - session, - out, - ).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to walk matched directory"); - } - chmodSync(lockedDir, 0o755); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + const lockedDir = path.join(workdir, "supabase", "schemas", "locked"); + yield* fs.makeDirectory(lockedDir, { recursive: true }); + yield* writeFile(fs, path, workdir, "supabase/schemas/locked/b.sql", "select 1;"); + yield* fs.chmod(lockedDir, 0o000); + const exit = yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas/locked"], + }, + session, + out, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Formatter.formatJson(exit.cause)).toContain("failed to walk matched directory"); + } + yield* fs.chmod(lockedDir, 0o755); + }), ); }, ); @@ -395,37 +441,40 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { // symlinked `DirEntry` — a symlinked `.sql` file is excluded regardless of target, and a // symlinked subdirectory is never even descended into. Both live OUTSIDE the matched // directory here, so applying either would mean executing SQL Go would never touch. - const workdir = makeWorkdir(); - const outsideDir = mkdtempSync(join(tmpdir(), "legacy-migrate-and-seed-outside-")); - writeFileSync(join(outsideDir, "escaped.sql"), "select 999;"); - writeFileSync(join(outsideDir, "linked-target.sql"), "select 888;"); - writeFile(workdir, "supabase/schemas/real.sql", "select 1;"); - symlinkSync( - join(outsideDir, "linked-target.sql"), - join(workdir, "supabase", "schemas", "link-to-file.sql"), - ); - symlinkSync(outsideDir, join(workdir, "supabase", "schemas", "link-to-dir")); const { session, execs } = fakeSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: ["supabase/schemas"], - }, - session, - out, - ).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs).toContain("select 1"); - expect(execs).not.toContain("select 888"); - expect(execs).not.toContain("select 999"); - rmSync(workdir, { recursive: true, force: true }); - rmSync(outsideDir, { recursive: true, force: true }); - }), + return withFixture((workdir, fs, path) => + Effect.acquireUseRelease( + fs.makeTempDirectory({ prefix: "legacy-migrate-and-seed-outside-" }), + (outsideDir) => + Effect.gen(function* () { + yield* writeFile(fs, path, outsideDir, "escaped.sql", "select 999;"); + yield* writeFile(fs, path, outsideDir, "linked-target.sql", "select 888;"); + yield* writeFile(fs, path, workdir, "supabase/schemas/real.sql", "select 1;"); + yield* fs.symlink( + path.join(outsideDir, "linked-target.sql"), + path.join(workdir, "supabase", "schemas", "link-to-file.sql"), + ); + yield* fs.symlink( + outsideDir, + path.join(workdir, "supabase", "schemas", "link-to-dir"), + ); + yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: ["supabase/schemas"], + }, + session, + out, + ); + expect(execs).toContain("select 1"); + expect(execs).not.toContain("select 888"); + expect(execs).not.toContain("select 999"); + }), + (outsideDir) => fs.remove(outsideDir, { recursive: true, force: true }), ), ); }, @@ -434,32 +483,29 @@ describe("legacyMigrateAndSeed experimental declarative-schema branch", () => { it.effect( "attaches the failing schema file as Go's CmdSuggestion (See schema file: <fp>)", () => { - const workdir = makeWorkdir(); const schemaPath = "supabase/schemas/broken.sql"; - writeFile(workdir, schemaPath, "totally not valid sql;"); const { session } = failingExecSession(); const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: [schemaPath], - }, - session, - out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error).toBeInstanceOf(LegacyMigrationApplyError); - const suggestion = (error as LegacyMigrationApplyError).suggestion; - expect(suggestion).toBeDefined(); - expect(stripAnsi(suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, workdir, schemaPath, "totally not valid sql;"); + const error = yield* run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: [schemaPath], + }, + session, + out, + ).pipe(Effect.flip, Effect.orDie); + expect(error).toBeInstanceOf(LegacyMigrationApplyError); + assertMigrationApplyError(error); + const suggestion = error.suggestion; + expect(suggestion).toBeDefined(); + expect(stripAnsi(suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); + }), ); }, ); @@ -471,98 +517,103 @@ describe("legacyMigrateAndSeed local pg_net remediation", () => { code: "3F000", }); - const setupMigration = (workdir: string) => + const setupMigration = (fs: FileSystem.FileSystem, path: Path.Path, workdir: string) => writeFile( + fs, + path, workdir, "supabase/migrations/20240101000000_webhook.sql", "select net.http_post(url := 'https://example.com');", ); it.effect("suggests enabling Database Webhooks when local replay cannot find pg_net", () => { - const workdir = makeWorkdir(); - setupMigration(workdir); const out = mockOutput(); - return run( - workdir, - "", - { ...baseConfig, localDatabaseWebhooksEnabled: false }, - pgNetFailureSession(missingNetSchema), - out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - assertMigrationApplyError(error); - expect(error.suggestion).toBe(LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION); - expect(error[ErrorActionabilityId]).toEqual(actionability.invalidConfig); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* setupMigration(fs, path, workdir); + const error = yield* run( + workdir, + "", + { ...baseConfig, localDatabaseWebhooksEnabled: false }, + pgNetFailureSession(missingNetSchema), + out, + ).pipe(Effect.flip, Effect.orDie); + assertMigrationApplyError(error); + expect(error.suggestion).toBe(LEGACY_ENABLE_LOCAL_WEBHOOKS_SUGGESTION); + expect(error[ErrorActionabilityId]).toEqual(actionability.invalidConfig); + }), ); }); it.effect("does not add the local hint when webhooks are enabled", () => { - const workdir = makeWorkdir(); - setupMigration(workdir); const out = mockOutput(); - return run( - workdir, - "", - { ...baseConfig, localDatabaseWebhooksEnabled: true }, - pgNetFailureSession(missingNetSchema), - out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - assertMigrationApplyError(error); - expect(error.suggestion).toBeUndefined(); - expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* setupMigration(fs, path, workdir); + const error = yield* run( + workdir, + "", + { ...baseConfig, localDatabaseWebhooksEnabled: true }, + pgNetFailureSession(missingNetSchema), + out, + ).pipe(Effect.flip, Effect.orDie); + assertMigrationApplyError(error); + expect(error.suggestion).toBeUndefined(); + expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); + }), ); }); it.effect("does not add the local hint for migration commands without local context", () => { - const workdir = makeWorkdir(); - setupMigration(workdir); const out = mockOutput(); - return run(workdir, "", baseConfig, pgNetFailureSession(missingNetSchema), out).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - assertMigrationApplyError(error); - expect(error.suggestion).toBeUndefined(); - expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* setupMigration(fs, path, workdir); + const error = yield* run( + workdir, + "", + baseConfig, + pgNetFailureSession(missingNetSchema), + out, + ).pipe(Effect.flip, Effect.orDie); + assertMigrationApplyError(error); + expect(error.suggestion).toBeUndefined(); + expect(error[ErrorActionabilityId]).toEqual(actionability.dbFinding); + }), ); }); }); describe("legacyMigrateAndSeed apply order", () => { it.effect("applies mixed-width versions in version order, like db push (#6036)", () => { - const workdir = makeWorkdir(); // `20260420010000_b.sql` precedes `20260420_a.sql` in file-name order // ('0' < '_'), the reverse of the version order `db push` applies in since // #6038. Unsorted, `db reset`/`db start` replay `b` before `a` locally while // `db push` sends `a` before `b` remotely. - writeFile(workdir, "supabase/migrations/20260420_a.sql", "create table t (id int);"); - writeFile(workdir, "supabase/migrations/20260420010000_b.sql", "alter table t add c int;"); const { session, execs } = fakeSession(); const out = mockOutput(); - return run(workdir, "", baseConfig, session, out).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(execs.filter((sql) => sql.includes("table t"))).toEqual([ - "create table t (id int)", - "alter table t add c int", - ]); - rmSync(workdir, { recursive: true, force: true }); - }), - ), + return withFixture((workdir, fs, path) => + Effect.gen(function* () { + yield* writeFile( + fs, + path, + workdir, + "supabase/migrations/20260420_a.sql", + "create table t (id int);", + ); + yield* writeFile( + fs, + path, + workdir, + "supabase/migrations/20260420010000_b.sql", + "alter table t add c int;", + ); + yield* run(workdir, "", baseConfig, session, out); + expect(execs.filter((sql) => sql.includes("table t"))).toEqual([ + "create table t (id int)", + "alter table t add c int", + ]); + }), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index f343028926..9d9816ac3b 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -373,21 +373,16 @@ const legacyParseScannerBufferSize = (raw: string): number => { * before a suggestion is ever set), so it carries no * suggestion, same as the file-open failure above. * - * `projectEnv`, when given, is the caller's already-loaded `legacyLoadProjectEnv` map: - * `loadNestedEnv` `os.Setenv`s every project-`.env` - * key that isn't already in the shell env BEFORE `ParseDatabaseConfig` returns — i.e. - * before ANY command body (including this scan) runs — so `viper.AutomaticEnv()` sees a - * `supabase/.env`-only `SUPABASE_SCANNER_BUFFER_SIZE` exactly like a real shell-exported - * one. Defaults to `{}` for callers that haven't threaded a project-env map through - * (shell-only, same as before this parameter existed). + * `projectEnv` is the caller's already-loaded `legacyLoadProjectEnv` map. The + * parser receives the fully resolved environment explicitly and never consults + * process-global environment state. */ export const checkScannerBufferSize = <E>( content: string, mapError: (message: string, phase: "read" | "exec") => E, - projectEnv: Readonly<Record<string, string>> = {}, + projectEnv: Readonly<Record<string, string>>, ): Effect.Effect<void, E> => { - const raw = - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; + const raw = projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; if (raw === undefined) return Effect.void; const configuredLimit = legacyParseScannerBufferSize(raw); // `configuredLimit <= 0` covers both an explicit non-positive size and an unparseable diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 0bacc45136..16f817c18a 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1,9 +1,8 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { Data, Effect, Exit, FileSystem, Layer, ManagedRuntime, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as Formatter from "effect/Formatter"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { @@ -11,16 +10,17 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import type { LegacyDbConnectError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbBatchStatement, LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, - legacyApplySchemaFiles, + legacyApplySchemaFiles as legacyApplySchemaFilesImpl, legacyHasTransactionControl, legacyIsPipelineIncompatible, legacyMarkError, legacyRevertsToLoginRole, - legacySeedGlobals, + legacySeedGlobals as legacySeedGlobalsImpl, } from "./legacy-migration-apply.ts"; class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} @@ -37,6 +37,95 @@ class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ } } +const testPlatform = ManagedRuntime.make(BunServices.layer); +const testPath = testPlatform.runSync(Path.Path); +const tempRoot = useLegacyTempWorkdir("legacy-migration-apply-"); + +function join(...paths: ReadonlyArray<string>): string { + return testPath.join(...paths); +} + +type FixtureOperation = Effect.Effect<void, PlatformError, FileSystem.FileSystem>; +const fixtureOperations = new Map<string, Array<FixtureOperation>>(); +let fixtureCounter = 0; + +function fixtureRoot(path: string): string | undefined { + return [...fixtureOperations.keys()] + .filter((root) => path === root || path.startsWith(`${root}/`)) + .sort((a, b) => b.length - a.length)[0]; +} + +function enqueueFixtureOperation(path: string, operation: FixtureOperation): void { + const root = fixtureRoot(path); + if (root === undefined) throw new Error(`fixture root not registered for ${path}`); + fixtureOperations.get(root)?.push(operation); +} + +function flushFixture(path: string): Effect.Effect<void, PlatformError, FileSystem.FileSystem> { + const root = fixtureRoot(path); + if (root === undefined) return Effect.void; + const operations = fixtureOperations.get(root) ?? []; + fixtureOperations.set(root, []); + return Effect.forEach(operations, (operation) => operation).pipe(Effect.asVoid); +} + +function mkdirSync(path: string, options?: { readonly recursive?: boolean }): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }), + ); +} + +function writeFileSync(path: string, data: string): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, data); + }), + ); +} + +function chmodSync(path: string, mode: number): void { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.chmod(path, mode); + }), + ); +} + +function mkdtempSync(prefix: string): string { + const path = join(tempRoot.current, `${prefix}${fixtureCounter++}`); + fixtureOperations.set(path, [ + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }), + ]); + return path; +} + +function rmSync( + path: string, + options?: { readonly recursive?: boolean; readonly force?: boolean }, +): void { + const root = fixtureRoot(path); + if (root !== undefined) { + enqueueFixtureOperation( + path, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }), + ); + } +} + function fakeSession( opts: { failOn?: string; @@ -110,24 +199,52 @@ const executedSql = ( const run = ( session: LegacyDbSession, migrationPath: string, -): Effect.Effect<void, TestError | LegacyDbConnectError> => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacyApplyMigrationFile( - session, - fs, - path, - migrationPath, - (message) => new TestError({ message }), - ); - }).pipe(Effect.provide(BunServices.layer)); +): Effect.Effect<void, TestError | LegacyDbConnectError | PlatformError> => + flushFixture(migrationPath).pipe( + Effect.flatMap(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* legacyApplyMigrationFile( + session, + fs, + path, + migrationPath, + (message) => new TestError({ message }), + ); + }), + ), + Effect.provide(BunServices.layer), + ); + +const legacyApplySchemaFiles = ( + session: LegacyDbSession, + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + schemaPaths: ReadonlyArray<string>, + mapError: (message: string, suggestion?: string) => TestError, + projectEnv: Readonly<Record<string, string>> = {}, +): Effect.Effect<void, TestError | LegacyDbConnectError | PlatformError, FileSystem.FileSystem> => + flushFixture(workdir).pipe( + Effect.flatMap(() => + legacyApplySchemaFilesImpl<TestError>( + session, + fs, + path, + workdir, + schemaPaths, + mapError, + projectEnv, + ), + ), + ); describe("legacyApplyMigrationFile", () => { it.effect( "creates the history table, then runs the statements + history insert in a transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_add_col.sql"); writeFileSync(file, "ALTER TABLE a ADD COLUMN b int;\nCREATE INDEX i ON a(b);"); const { session, calls } = fakeSession(); @@ -173,7 +290,7 @@ describe("legacyApplyMigrationFile", () => { ); it.effect("records a versioned empty migration in one batch", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_empty.sql"); writeFileSync(file, ""); const { session, calls } = fakeSession(); @@ -195,7 +312,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("rolls back and maps the error when a statement fails", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_boom.sql"); writeFileSync(file, "ALTER TABLE a ADD COLUMN b int;"); const { session, calls } = fakeSession({ failOn: "ADD COLUMN b int" }); @@ -207,7 +324,7 @@ describe("legacyApplyMigrationFile", () => { expect(calls.filter((call) => call.kind === "batch")).toHaveLength(1); // Go's ExecBatch appends the failing statement number + text for context. if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("At statement: 0"); expect(msg).toContain("ALTER TABLE a ADD COLUMN b int"); } @@ -218,7 +335,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("sends a large compatible migration in one batch", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_many.sql"); const statements = Array.from({ length: 10_000 }, (_, index) => `SELECT ${index + 1}`); writeFileSync(file, `${statements.join(";\n")};`); @@ -240,7 +357,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("keeps the global error index after an incompatible-statement flush", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail_after_vacuum.sql"); writeFileSync(file, "SELECT 1;\nVACUUM;\nSELECT missing_column;\nSELECT 4;"); const { session } = fakeSession({ failOn: "missing_column" }); @@ -257,7 +374,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("defaults a deferred batch failure to the migration history statement", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "SELECT 1;"); const { session } = fakeSession({ failAfterBatch: true }); @@ -281,7 +398,7 @@ describe("legacyApplyMigrationFile", () => { // `ApplyMigrations`/`applySchemaFiles` ever get a chance to attach a // `CmdSuggestion` — a read failure here must carry the same prefix, not the bare // platform error text. - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-read-fail-")); + const dir = mkdtempSync("legacy-apply-read-fail-"); const missingFile = join(dir, "20240101120000_missing.sql"); const { session } = fakeSession(); return run(session, missingFile).pipe( @@ -290,7 +407,7 @@ describe("legacyApplyMigrationFile", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("failed to open migration file: "); } rmSync(dir, { recursive: true, force: true }); @@ -301,7 +418,7 @@ describe("legacyApplyMigrationFile", () => { ); it.effect("runs a pipeline-incompatible statement outside the surrounding transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_add_index.sql"); writeFileSync( file, @@ -335,7 +452,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("honors pg-delta's file-level no-transaction directive", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_drop_subscription.sql"); writeFileSync( file, @@ -377,7 +494,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("resets the session and omits history when a no-transaction migration fails", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_drop_subscription.sql"); writeFileSync( file, @@ -396,7 +513,7 @@ describe("legacyApplyMigrationFile", () => { expect(execs.at(-1)).toBe("RESET ALL"); expect(calls.some((call) => call.kind === "query")).toBe(false); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("At statement: 1"); + expect(Formatter.formatJson(exit.cause)).toContain("At statement: 1"); } rmSync(dir, { recursive: true, force: true }); }), @@ -405,7 +522,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("reports a pipeline-incompatible statement failure with its statement index", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_add_index.sql"); writeFileSync(file, "create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);"); const { session, calls } = fakeSession({ failOn: "CONCURRENTLY" }); @@ -415,7 +532,7 @@ describe("legacyApplyMigrationFile", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); // Index 1: the leading `create table a` (index 0) committed in its own batch first. expect(msg).toContain("At statement: 1"); expect(msg).toContain("CREATE INDEX CONCURRENTLY a_idx ON a(id)"); @@ -433,7 +550,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("preserves authored transaction boundaries and records history afterwards", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_authored.sql"); writeFileSync(file, "BEGIN;\nSET LOCAL check_function_bodies = off;\nCOMMIT;"); const { session, calls } = fakeSession(); @@ -456,7 +573,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("keeps savepoint rollback inside the managed migration transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_savepoint.sql"); writeFileSync( file, @@ -490,7 +607,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("does not record history when an authored transaction fails", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_authored.sql"); writeFileSync(file, "BEGIN;\nCREATE TABLE broken (;\nCOMMIT;"); const { session, calls } = fakeSession({ failOn: "CREATE TABLE broken" }); @@ -513,7 +630,7 @@ describe("legacyApplyMigrationFile", () => { it.effect( "re-asserts the stepped-down role between the statements and the history insert", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_reset_role.sql"); writeFileSync(file, "set role repro_writer;\ncreate table t (id int);\nreset role;"); const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); @@ -545,7 +662,7 @@ describe("legacyApplyMigrationFile", () => { ); it.effect("never re-asserts a role on sessions that did not step down", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_reset_role.sql"); writeFileSync(file, "reset role;"); const { session, calls } = fakeSession(); @@ -560,7 +677,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("re-asserts the stepped-down role before recording an authored transaction", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_authored.sql"); writeFileSync(file, "BEGIN;\nreset role;\nCOMMIT;"); const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); @@ -583,7 +700,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("keeps the history insert's statement index when the role restore precedes it", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "SELECT 1;"); const { session } = fakeSession({ @@ -609,7 +726,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("keeps a mid-batch failure's statement index when a restore op is appended", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "SELECT 1;\nSELECT bad_col;\nSELECT 3;"); const { session } = fakeSession({ @@ -632,7 +749,7 @@ describe("legacyApplyMigrationFile", () => { // Mirrors "defaults a deferred batch failure to the migration history // statement": the restore op between the statements and the insert must not // shift the deferred (post-Sync) index either. - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "SELECT 1;"); const { session } = fakeSession({ @@ -652,7 +769,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("reports the restore op's own failure with the history step's index", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "SELECT 1;"); const { session } = fakeSession({ @@ -678,7 +795,7 @@ describe("legacyApplyMigrationFile", () => { it.effect("keeps the insert index when the final batch holds only the trailing ops", () => { // A trailing CONCURRENTLY statement empties `pending`, so the final batch is // just [restore, insert] — the index math must still report the file's count. - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "SELECT 1;\nCREATE INDEX CONCURRENTLY i ON a(id);"); const { session } = fakeSession({ @@ -700,7 +817,7 @@ describe("legacyApplyMigrationFile", () => { it.effect("restores postgres immediately after a mid-file RESET ROLE, silently", () => { // Statements after the reset now run as postgres again (avallete's #6246 // review), so the old drift WARN is gone — there is no drift left to surface. - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_reset_role.sql"); writeFileSync(file, "set role r;\nreset role;\nselect 1;"); const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); @@ -731,7 +848,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("restores postgres after every static role-revert spelling", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_role_none.sql"); writeFileSync( file, @@ -763,7 +880,7 @@ describe("legacyApplyMigrationFile", () => { it.effect("emits exactly one restore when a no-transaction file ends in a revert", () => { // Sequential path: the injected restore after the trailing `reset role` // makes the end-of-file restore redundant, so it must dedupe away. - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_seq_reset.sql"); writeFileSync(file, "-- pg-delta: transaction=false\nset role r;\nreset role;"); const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); @@ -783,7 +900,7 @@ describe("legacyApplyMigrationFile", () => { it.effect("keeps the deferred-failure index when mid-file restores were injected", () => { // The `injectedBefore[raw] ?? injected` fallback only matters when the // deferred (post-Sync) index lands past the ops array AND injections exist. - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "set role r;\nreset role;\nselect 1;"); const { session } = fakeSession({ @@ -803,7 +920,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("injects into intermediate flushes so standalone statements run as postgres", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_concurrent.sql"); writeFileSync(file, "reset role;\nCREATE INDEX CONCURRENTLY i ON a(id);\nselect 2;"); const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); @@ -827,7 +944,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("reports a mid-file restore's own failure at its host statement", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "set role r;\nreset role;\nselect 1;"); const { session } = fakeSession({ @@ -849,7 +966,7 @@ describe("legacyApplyMigrationFile", () => { }); it.effect("keeps statement numbering across an injected mid-file restore", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "set role r;\nreset role;\nselect bad;"); const { session } = fakeSession({ @@ -905,7 +1022,7 @@ describe("migration failure rendering (Go ExecBatch parity)", () => { sql: string, failWith: { message: string; code?: string; detail?: string; position?: number }, ) => { - const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const dir = mkdtempSync("legacy-apply-"); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, `${sql};`); const { session } = fakeSession({ failOn: sql, failWith }); @@ -1125,14 +1242,21 @@ describe("legacyIsPipelineIncompatible", () => { describe("legacySeedGlobals", () => { it.effect("runs the globals file WITHOUT RESET ALL and without a history insert", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-globals-")); + const dir = mkdtempSync("legacy-globals-"); const file = join(dir, "roles.sql"); writeFileSync(file, "CREATE ROLE my_role;"); const { session, calls } = fakeSession(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message })); + yield* flushFixture(file); + yield* legacySeedGlobalsImpl<TestError>( + session, + fs, + path, + [file], + (message) => new TestError({ message }), + ); const execs = executedSql(calls); // Go's SeedGlobals calls ExecBatch directly — no RESET ALL (that's only the // migration-apply path) and no schema-migrations history insert. @@ -1143,22 +1267,28 @@ describe("legacySeedGlobals", () => { ).toBe(false); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), + Effect.provide(BunServices.layer.pipe(Layer.merge(mockOutput({ format: "text" }).layer))), ); }); it.effect("leaves a stepped-down session role-clean after a globals file", () => { // Globals run before the vault upsert and the history-table DDL on the same // session, so a `reset role` here must not leak the login role into them. - const dir = mkdtempSync(join(tmpdir(), "legacy-globals-")); + const dir = mkdtempSync("legacy-globals-"); const file = join(dir, "roles.sql"); writeFileSync(file, "CREATE ROLE my_role;\nset role my_role;\nreset role;"); const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - yield* legacySeedGlobals(session, fs, path, [file], (message) => new TestError({ message })); + yield* flushFixture(file); + yield* legacySeedGlobalsImpl<TestError>( + session, + fs, + path, + [file], + (message) => new TestError({ message }), + ); const batch = calls.find((call) => call.kind === "batch"); expect(batch?.statements?.at(-1)?.sql).toBe("SET SESSION ROLE postgres"); expect( @@ -1166,8 +1296,7 @@ describe("legacySeedGlobals", () => { ).toBe(false); rmSync(dir, { recursive: true, force: true }); }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), + Effect.provide(BunServices.layer.pipe(Layer.merge(mockOutput({ format: "text" }).layer))), ); }); }); @@ -1186,7 +1315,7 @@ describe("legacyApplySchemaFiles", () => { // `stat` only needs directory execute permission, not read permission on the // file itself, so this still resolves as a `"File"` match, unlike a directory // (which the glob would instead expand via `legacyWalkSqlFiles`). - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-read-fail-")); + const dir = mkdtempSync("legacy-schema-files-read-fail-"); const file = join(dir, "supabase", "unreadable.sql"); mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync(file, "select 1;"); @@ -1206,7 +1335,7 @@ describe("legacyApplySchemaFiles", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("failed to open migration file: "); expect(msg).toContain("supabase/unreadable.sql"); expect(msg).not.toContain(dir); @@ -1227,15 +1356,13 @@ describe("legacyApplySchemaFiles", () => { // statement's raw byte length, this fails with `bufio.Scanner: token too long` // instead of silently applying the oversized statement — verified empirically // (a `parser.SplitAndTrim` scratch probe). - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-")); + const dir = mkdtempSync("legacy-schema-files-scanner-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); // A single, un-splittable statement whose raw text exceeds the 4096-byte floor // (`bufio.Scanner` starts at that size regardless of the configured limit). writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1247,22 +1374,17 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); expect(msg).toContain("After statement 1: SELECT 1;"); expect(msg).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1278,13 +1400,11 @@ describe("legacyApplySchemaFiles", () => { // message still reports that lone ";" as the last-scanned text, not a blank // token — `len(stats)` (this port's `emitted`) stays gated on non-empty trim, // but the reported RAW text must not share that gate. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-empty-token-")); + const dir = mkdtempSync("legacy-schema-files-scanner-empty-token-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `;\nSELECT '${"a".repeat(5000)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1296,10 +1416,11 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); // 0 statements were EMITTED (the lone ";" trimmed to empty and was never // appended), but the last scanned RAW token (";") must still show — not a @@ -1307,13 +1428,7 @@ describe("legacyApplySchemaFiles", () => { expect(msg).toContain("After statement 0: ;"); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1322,13 +1437,11 @@ describe("legacyApplySchemaFiles", () => { it.effect( "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (Go's default auto-grows to file size)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-default-")); + const dir = mkdtempSync("legacy-schema-files-scanner-default-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); const { session, calls } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1343,12 +1456,7 @@ describe("legacyApplySchemaFiles", () => { ); expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous !== undefined) process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1365,13 +1473,11 @@ describe("legacyApplySchemaFiles", () => { // `parser.Split` falls back to its OWN hardcoded default cap // (`MaxScannerCapacity`, 256KiB), not to "no limit" and not to a tiny 5-byte // limit either. A statement past that hardcoded default must still fail. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-garbage-")); + const dir = mkdtempSync("legacy-schema-files-scanner-garbage-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5M"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1383,10 +1489,11 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "5M" }, ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); // 256KiB (`parser.MaxScannerCapacity` default), not "5MB" and not ~0KB. expect(msg).toContain( @@ -1394,13 +1501,7 @@ describe("legacyApplySchemaFiles", () => { ); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1417,13 +1518,11 @@ describe("legacyApplySchemaFiles", () => { // string outright and silently fall back to the 256KiB default instead, so a // statement between 5120 and 262144 bytes would apply in TS but Go would // already have failed with "bufio.Scanner: token too long" at 5121 bytes. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-hex-")); + const dir = mkdtempSync("legacy-schema-files-scanner-hex-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0x1400"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1435,10 +1534,11 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "0x1400" }, ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); // 5KiB (0x1400 bytes), not the 256KiB hardcoded fallback a decimal-only // parser would have silently used instead. @@ -1447,13 +1547,7 @@ describe("legacyApplySchemaFiles", () => { ); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1470,13 +1564,11 @@ describe("legacyApplySchemaFiles", () => { // 256KiB default instead, so a statement between 5120 and 262144 bytes would // apply in TS but Go would already have failed with "bufio.Scanner: token too // long" at 5121 bytes. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-underscore-")); + const dir = mkdtempSync("legacy-schema-files-scanner-underscore-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5_120"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1488,10 +1580,11 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "5_120" }, ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); // 5KiB (5_120 bytes), not the 256KiB hardcoded fallback an // underscore-rejecting parser would have silently used instead. @@ -1500,13 +1593,7 @@ describe("legacyApplySchemaFiles", () => { ); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1521,13 +1608,11 @@ describe("legacyApplySchemaFiles", () => { // invalid in real Go (`strconv.ParseInt("_5120", 0, 64)` errors), so it falls // back to the same 256KiB default as a genuinely unset/unparseable value — // verified empirically against the real Go `strconv.ParseInt`. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-bad-underscore-")); + const dir = mkdtempSync("legacy-schema-files-scanner-bad-underscore-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "_5120"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1539,18 +1624,13 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "_5120" }, ).pipe(Effect.exit); // The 5116-byte statement fits comfortably under the 256KiB default // fallback, so an invalid underscore placement must NOT fail the apply. expect(Exit.isSuccess(exit)).toBe(true); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1568,13 +1648,11 @@ describe("legacyApplySchemaFiles", () => { // genuinely unparseable value ("5M" above) — NOT to "no limit". Verified // empirically against the pinned `spf13/cast@v1.10.0` // (`cast.ToInt("9223372036854775808")` → `0`). - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-int64-overflow-")); + const dir = mkdtempSync("legacy-schema-files-scanner-int64-overflow-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(300_000)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "9223372036854775808"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1586,10 +1664,11 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "9223372036854775808" }, ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); // 256KiB (Go's hardcoded default), not "no limit" — a treat-as-unbounded // bug would let this 300_000-byte statement apply successfully instead. @@ -1598,13 +1677,7 @@ describe("legacyApplySchemaFiles", () => { ); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1618,13 +1691,11 @@ describe("legacyApplySchemaFiles", () => { // are. A range check that's off-by-one in the strict direction would wrongly // reject this legitimate (if enormous) configured size and fall back to the // 256KiB default instead of the requested cap. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-int64-boundary-")); + const dir = mkdtempSync("legacy-schema-files-scanner-int64-boundary-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "9223372036854775807"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1636,18 +1707,13 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "9223372036854775807" }, ).pipe(Effect.exit); // The 5116-byte statement fits comfortably under the (enormous) configured // limit, so this must succeed, not fall back to the 256KiB default. expect(Exit.isSuccess(exit)).toBe(true); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1662,13 +1728,11 @@ describe("legacyApplySchemaFiles", () => { // `SUPABASE_SCANNER_BUFFER_SIZE` exactly like a real shell-exported one. // `legacyApplySchemaFiles`'s `projectEnv` parameter threads the caller's already // -loaded `legacyLoadProjectEnv` map through to the same check. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-projectenv-")); + const dir = mkdtempSync("legacy-schema-files-scanner-projectenv-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1684,16 +1748,11 @@ describe("legacyApplySchemaFiles", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); + const msg = Formatter.formatJson(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); } }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous !== undefined) process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, @@ -1705,15 +1764,13 @@ describe("legacyApplySchemaFiles", () => { // `godotenv.Load`'s `overload=false` never sets a key already present in // `os.Environ()` — the shell value must // win even when a (different) project-env value is also threaded through. - const dir = mkdtempSync(join(tmpdir(), "legacy-schema-files-scanner-shellwins-")); + const dir = mkdtempSync("legacy-schema-files-scanner-shellwins-"); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); const { session, calls } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; // Shell explicitly unsets enforcement (0 → treated as unset, no check) while the // project env sets a tiny limit — the shell value must win. - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1725,17 +1782,11 @@ describe("legacyApplySchemaFiles", () => { ["supabase/big.sql"], (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, + { SUPABASE_SCANNER_BUFFER_SIZE: "0" }, ); expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), Effect.provide(BunServices.layer), ); }, diff --git a/apps/cli/src/legacy/shared/legacy-migration-file.ts b/apps/cli/src/legacy/shared/legacy-migration-file.ts index a1f8fe8a36..ffe23f1ea1 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-file.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-file.ts @@ -1,3 +1,4 @@ +import { DateTime } from "effect"; import type { Path } from "effect"; import { legacySplitAndTrim } from "./legacy-sql-split.ts"; @@ -45,7 +46,7 @@ export function legacyParseMigrationContent(content: string): LegacyParsedMigrat * it stays deterministic under test. */ export function legacyFormatMigrationTimestamp(millis: number): string { - return new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); + return DateTime.formatIso(DateTime.makeUnsafe(millis)).replace(/\D/gu, "").slice(0, 14); } /** diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.ts b/apps/cli/src/legacy/shared/legacy-migration-history.ts index 3242eef54a..b274b903e2 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.ts @@ -112,6 +112,16 @@ export interface LegacySeedRow { readonly hash: string; } +const legacyMigrationCell = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; + /** * Reads `supabase_migrations.seed_files` (path → hash). Mirrors Go's * `getRemoteSeeds`: a missing table (42P01) means no @@ -121,8 +131,8 @@ export const legacyReadSeedTable = (session: LegacyDbSession) => session.query(SELECT_SEED_TABLE).pipe( Effect.map((rows) => rows.map<LegacySeedRow>((row) => ({ - path: String(row["path"] ?? ""), - hash: String(row["hash"] ?? ""), + path: legacyMigrationCell(row["path"]), + hash: legacyMigrationCell(row["hash"]), })), ), Effect.catch((error) => @@ -411,7 +421,7 @@ export interface LegacyMigrationFile { /** Coerce a Postgres `text[]` column value into a string array. */ const toStatements = (value: unknown): ReadonlyArray<string> => - Array.isArray(value) ? value.map((entry) => String(entry)) : []; + Array.isArray(value) ? value.map(legacyMigrationCell) : []; /** * Reads the full migration-history rows (version, name, statements). Mirrors Go's @@ -421,8 +431,8 @@ export const legacyReadMigrationTable = (session: LegacyDbSession) => session.query(SELECT_VERSION_TABLE).pipe( Effect.map((rows) => rows.map<LegacyMigrationFile>((row) => ({ - version: String(row["version"] ?? ""), - name: String(row["name"] ?? ""), + version: legacyMigrationCell(row["version"]), + name: legacyMigrationCell(row["name"]), statements: toStatements(row["statements"]), })), ), diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index 4a069d595f..1890352ea3 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -1,5 +1,5 @@ import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { stripAnsi } from "../../../tests/helpers/ansi.ts"; import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; @@ -102,38 +102,48 @@ describe("legacyReconcileMigrations", () => { describe("legacyListRemoteMigrations (suppress only undefined_table, like Go)", () => { const run = (error: LegacyDbExecError) => - Effect.runPromiseExit(legacyListRemoteMigrations(failingSession(error))); - - it("treats a missing history table (42P01) as an empty history", async () => { - const exit = await run( - new LegacyDbExecError({ - message: 'relation "supabase_migrations.schema_migrations" does not exist', - code: "42P01", - }), - ); - expect(exit).toStrictEqual(Exit.succeed([])); - }); - - it("propagates a malformed table (undefined column 42703) instead of swallowing it", async () => { - const exit = await run( - new LegacyDbExecError({ message: 'column "version" does not exist', code: "42703" }), - ); - expect(Exit.isFailure(exit)).toBe(true); - }); - - it("falls back to a relation-not-exist message when no SQLSTATE is surfaced", async () => { - const exit = await run( - new LegacyDbExecError({ - message: 'relation "supabase_migrations.schema_migrations" does not exist', - }), - ); - expect(exit).toStrictEqual(Exit.succeed([])); - }); - - it("does not swallow a column-not-exist message when no SQLSTATE is surfaced", async () => { - const exit = await run(new LegacyDbExecError({ message: 'column "version" does not exist' })); - expect(Exit.isFailure(exit)).toBe(true); - }); + Effect.exit(legacyListRemoteMigrations(failingSession(error))); + + it.effect("treats a missing history table (42P01) as an empty history", () => + Effect.gen(function* () { + const exit = yield* run( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.schema_migrations" does not exist', + code: "42P01", + }), + ); + expect(exit).toStrictEqual(Exit.succeed([])); + }), + ); + + it.effect("propagates a malformed table (undefined column 42703) instead of swallowing it", () => + Effect.gen(function* () { + const exit = yield* run( + new LegacyDbExecError({ message: 'column "version" does not exist', code: "42703" }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); + + it.effect("falls back to a relation-not-exist message when no SQLSTATE is surfaced", () => + Effect.gen(function* () { + const exit = yield* run( + new LegacyDbExecError({ + message: 'relation "supabase_migrations.schema_migrations" does not exist', + }), + ); + expect(exit).toStrictEqual(Exit.succeed([])); + }), + ); + + it.effect("does not swallow a column-not-exist message when no SQLSTATE is surfaced", () => + Effect.gen(function* () { + const exit = yield* run( + new LegacyDbExecError({ message: 'column "version" does not exist' }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); }); describe("legacyFindPendingMigrations (Go TestPendingMigrations / TestIgnoreVersionMismatch)", () => { @@ -203,27 +213,27 @@ describe("legacySuggestRevertHistory", () => { }); describe("legacyResolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { - it("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", async () => { - // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP - // private-use character (U+E000): JS's default `.sort()` (no comparator) ranks the - // surrogate pair FIRST — its leading high-surrogate code unit (0xD83D) is less than - // the private-use code unit (0xE000). `sort.Strings` (UTF-8 byte order) ranks the - // private-use character first instead (0xEE... < 0xF0... in its UTF-8 encoding). - const surrogatePair = "20240101000000_a\u{1f600}.sql"; - const privateUse = "20240101000000_a\u{e000}.sql"; - expect([surrogatePair, privateUse].sort()[0]).toBe(surrogatePair); - - const layer = Layer.mergeAll( - Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readDirectory: () => Effect.succeed([surrogatePair, privateUse]), - }), - ), - Path.layer, - ); - const result = await Effect.runPromise( - Effect.gen(function* () { + it.effect("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", () => + Effect.gen(function* () { + // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP + // private-use character (U+E000): JS's default `.sort()` (no comparator) ranks the + // surrogate pair FIRST — its leading high-surrogate code unit (0xD83D) is less than + // the private-use code unit (0xE000). `sort.Strings` (UTF-8 byte order) ranks the + // private-use character first instead (0xEE... < 0xF0... in its UTF-8 encoding). + const surrogatePair = "20240101000000_a\u{1f600}.sql"; + const privateUse = "20240101000000_a\u{e000}.sql"; + expect([surrogatePair, privateUse].sort()[0]).toBe(surrogatePair); + + const layer = Layer.mergeAll( + Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readDirectory: () => Effect.succeed([surrogatePair, privateUse]), + }), + ), + Path.layer, + ); + const result = yield* Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* legacyResolveMigrationFile( @@ -232,10 +242,10 @@ describe("legacyResolveMigrationFile (byte-ordered match, Go's sort.Strings via "/supabase/migrations", "20240101000000", ); - }).pipe(Effect.provide(layer)), - ); - expect(Option.isSome(result) ? result.value : undefined).toBe( - `/supabase/migrations/${privateUse}`, - ); - }); + }).pipe(Effect.provide(layer)); + expect(Option.isSome(result) ? result.value : undefined).toBe( + `/supabase/migrations/${privateUse}`, + ); + }), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts b/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts index 91b78322c2..44fd67572d 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-timestamp.format.ts @@ -29,15 +29,14 @@ export function legacyFormatTimestampVersion(version: string): string { if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) { return version; } - const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== month - 1 || - date.getUTCDate() !== day - ) { - return version; - } - return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`; + return DateTime.make(`${yyyy}-${mm}-${dd}T${hh}:${min}:${ss}Z`).pipe( + Option.filter((date) => { + const parts = DateTime.toPartsUtc(date); + return parts.year === year && parts.month === month && parts.day === day; + }), + Option.map(() => `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`), + Option.getOrElse(() => version), + ); } /** @@ -85,3 +84,4 @@ export function legacySortMigrationVersions( ): ReadonlyArray<string> { return [...versions].sort(legacyCompareMigrationVersions); } +import { DateTime, Option } from "effect"; diff --git a/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts b/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts index cdc60e56b9..592a18091c 100644 --- a/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts +++ b/apps/cli/src/legacy/shared/legacy-pflag-reconcile.ts @@ -13,6 +13,7 @@ import { ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; import { legacyProfileFilePath } from "../config/legacy-profile-file.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; import { legacyLoadProfile, type LegacyLoadedProfile } from "./legacy-profile-load.ts"; import { legacyParseStringSliceFlag } from "./legacy-string-slice-flag.ts"; import { legacyValidateWorkdirIsDirectory } from "./legacy-workdir-validation.ts"; @@ -173,7 +174,11 @@ export const legacyValidatePflagWorkdir = Effect.fnUntraced(function* ( // `serviceOption`: absent outside the real CLI tree (handler-level tests // provide argv via `Stdio.layerTest`, not the global flag settings). const parsedWorkdir = Option.flatten(yield* Effect.serviceOption(LegacyWorkdirFlag)); - const workdir = legacyPflagWorkdirValue(scan, parsedWorkdir, process.env["SUPABASE_WORKDIR"]); + const env = yield* LegacyViperEnv; + const envWorkdir = yield* env + .get("SUPABASE_WORKDIR") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); + const workdir = legacyPflagWorkdirValue(scan, parsedWorkdir, Option.getOrUndefined(envWorkdir)); if (Option.isNone(workdir)) { return; } @@ -270,8 +275,17 @@ export const legacyResolvePflagProfile = Effect.fnUntraced(function* ( ) { const parsedRaw = yield* Effect.serviceOption(LegacyProfileFlag); const parsedProfile = Option.filter(parsedRaw, (value) => value !== "supabase"); - const env = process.env["SUPABASE_PROFILE"]; - const envProfile = env !== undefined && env.length > 0 ? env : undefined; + const envService = yield* LegacyViperEnv; + const envHome = yield* envService + .get("SUPABASE_HOME") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); + const envValue = yield* envService + .get("SUPABASE_PROFILE") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); + const envProfile = Option.match(envValue, { + onNone: () => undefined, + onSome: (value) => (value.length > 0 ? value : undefined), + }); // viper-effective explicit token vs the config layer's explicit token. // The layer-model MUST mirror `resolveProfile` exactly (raw argv scan → @@ -317,7 +331,9 @@ export const legacyResolvePflagProfile = Effect.fnUntraced(function* ( // comparison below surfaces (e.g. a trailing newline fails with // `Unsupported Config Type ""`, binary-verified). const fileRaw = yield* fs.value - .readFileString(legacyProfileFilePath(path.value, runtimeInfo.value.homeDir)) + .readFileString( + legacyProfileFilePath(path.value, runtimeInfo.value.homeDir, Option.getOrUndefined(envHome)), + ) .pipe(Effect.option); const goToken = Option.isSome(goExplicit) diff --git a/apps/cli/src/legacy/shared/legacy-pflag-reconcile.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pflag-reconcile.unit.test.ts index 4291c4ce1e..4c3acabac7 100644 --- a/apps/cli/src/legacy/shared/legacy-pflag-reconcile.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pflag-reconcile.unit.test.ts @@ -1,14 +1,11 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option, Result } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path, Result } from "effect"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { LegacyProfileFlag } from "../../shared/legacy/global-flags.ts"; import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; import { legacyPflagBoolValue, legacyPflagEnumValue, @@ -357,25 +354,17 @@ describe("legacyPflagProfileValue", () => { }); describe("legacyResolvePflagProfile", () => { - const withEnvProfile = <A, E, R>(value: string, effect: Effect.Effect<A, E, R>) => { - const prev = process.env["SUPABASE_PROFILE"]; - process.env["SUPABASE_PROFILE"] = value; - return effect.pipe( - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["SUPABASE_PROFILE"]; - else process.env["SUPABASE_PROFILE"] = prev; - }), - ), - ); - }; - - const services = (args: ReadonlyArray<string>, homeDir: string) => + const services = ( + args: ReadonlyArray<string>, + homeDir: string, + env: Readonly<Record<string, string>>, + ) => Layer.mergeAll( BunServices.layer, Layer.succeed(LegacyProfileFlag, "supabase"), Layer.succeed(CliArgs, { args }), mockRuntimeInfo({ homeDir }), + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })), ); // `--domains --profile supabase`: pflag consumes the `--profile` token, so @@ -385,55 +374,66 @@ describe("legacyResolvePflagProfile", () => { it.effect( "re-loads the env profile when the layer's scan wrongly shadowed a consumed token", () => { - const dir = mkdtempSync(join(tmpdir(), "supabase-pflag-reconcile-")); - const profilePath = join(dir, "env.yml"); - writeFileSync( - profilePath, - [ - "name: harness", - "api_url: http://127.0.0.1:45555", - "dashboard_url: http://127.0.0.1:45555", - "project_host: localhost", - ].join("\n"), - ); - return withEnvProfile( - profilePath, + return Effect.scoped( Effect.gen(function* () { - const resolved = yield* legacyResolvePflagProfile({ - occurrences: new Map(), - consumedFlagNames: new Set(["profile"]), - prePathOccurrences: new Map(), - }); - expect(Option.isSome(resolved)).toBe(true); - if (Option.isSome(resolved)) { - expect(resolved.value.name).toBe("harness"); - expect(resolved.value.apiUrl).toBe("http://127.0.0.1:45555"); - } - }).pipe( - Effect.provide( - services(["sso", "add", "--type", "saml", "--domains", "--profile", "supabase"], dir), - ), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + const profilePath = path.join(dir, "env.yml"); + yield* fs.writeFileString( + profilePath, + [ + "name: harness", + "api_url: http://127.0.0.1:45555", + "dashboard_url: http://127.0.0.1:45555", + "project_host: localhost", + ].join("\n"), + ); + yield* Effect.gen(function* () { + const resolved = yield* legacyResolvePflagProfile({ + occurrences: new Map(), + consumedFlagNames: new Set(["profile"]), + prePathOccurrences: new Map(), + }); + expect(Option.isSome(resolved)).toBe(true); + if (Option.isSome(resolved)) { + expect(resolved.value.name).toBe("harness"); + expect(resolved.value.apiUrl).toBe("http://127.0.0.1:45555"); + } + }).pipe( + Effect.provide( + services( + ["sso", "add", "--type", "saml", "--domains", "--profile", "supabase"], + dir, + { SUPABASE_PROFILE: profilePath }, + ), + ), + ); + }).pipe(Effect.provide(BunServices.layer)), ); }, ); it.effect("returns none when the scan and the layer agree on an explicit supabase", () => { - const dir = mkdtempSync(join(tmpdir(), "supabase-pflag-reconcile-")); - return withEnvProfile( - "rogue-profile", + return Effect.scoped( Effect.gen(function* () { - const resolved = yield* legacyResolvePflagProfile({ - occurrences: new Map([["profile", ["supabase"]]]), - consumedFlagNames: new Set<string>(), - prePathOccurrences: new Map(), - }); - expect(Option.isNone(resolved)).toBe(true); - }).pipe( - Effect.provide(services(["sso", "add", "--profile", "supabase"], dir)), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ), + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-pflag-reconcile-" }); + yield* Effect.gen(function* () { + const resolved = yield* legacyResolvePflagProfile({ + occurrences: new Map([["profile", ["supabase"]]]), + consumedFlagNames: new Set<string>(), + prePathOccurrences: new Map(), + }); + expect(Option.isNone(resolved)).toBe(true); + }).pipe( + Effect.provide( + services(["sso", "add", "--profile", "supabase"], dir, { + SUPABASE_PROFILE: "rogue-profile", + }), + ), + ); + }).pipe(Effect.provide(BunServices.layer)), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts index 92957b38a0..cfde449d54 100644 --- a/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.env.unit.test.ts @@ -1,6 +1,7 @@ -import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; import { @@ -42,7 +43,8 @@ const baseOpt: LegacyDumpOptions = { const goScriptsDir = fileURLToPath( new URL("../../../../cli-go/pkg/migration/scripts/", import.meta.url), ); -const readGoScript = (name: string) => readFileSync(`${goScriptsDir}${name}`, "utf8"); +const readGoScript = (name: string, fs: FileSystem.FileSystem) => + fs.readFileString(`${goScriptsDir}${name}`); describe("legacyToDumpEnv", () => { it("maps the connection to PG* env vars (port stringified)", () => { @@ -152,9 +154,12 @@ describe("legacyExpandScript", () => { }); describe("embedded dump scripts", () => { - it("match the Go sources byte-for-byte", () => { - expect(legacyDumpSchemaScript).toBe(readGoScript("dump_schema.sh")); - expect(legacyDumpDataScript).toBe(readGoScript("dump_data.sh")); - expect(legacyDumpRoleScript).toBe(readGoScript("dump_role.sh")); - }); + it.effect("match the Go sources byte-for-byte", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(legacyDumpSchemaScript).toBe(yield* readGoScript("dump_schema.sh", fs)); + expect(legacyDumpDataScript).toBe(yield* readGoScript("dump_data.sh", fs)); + expect(legacyDumpRoleScript).toBe(yield* readGoScript("dump_role.sh", fs)); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts index 3194df9546..8d62f2bbf7 100644 --- a/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts +++ b/apps/cli/src/legacy/shared/legacy-pg-dump.run.ts @@ -2,6 +2,7 @@ import { Effect, Option } from "effect"; import { LegacyNetworkIdFlag } from "../../shared/legacy/global-flags.ts"; import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { legacyGetRegistryImageUrl } from "./legacy-docker-registry.ts"; import { LegacyDockerRun } from "./legacy-docker-run.service.ts"; @@ -42,6 +43,7 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* <E>(params: { const docker = yield* LegacyDockerRun; const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* LegacyNetworkIdFlag; + yield* LegacyViperEnv; // `dockerExec` sets `NetworkMode` to host, but // `DockerStart` then overrides it with `viper.GetString("network-id")` whenever @@ -52,7 +54,7 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* <E>(params: { // left `NetworkMode` empty, which the dump path never does, so the effective // pg_dump fallback is host networking, not the generated `supabase_network_*`. const networkId = Option.getOrUndefined(networkIdFlag); - const envNetworkId = legacyViperEnvStringWithProjectFallback( + const envNetworkId = yield* legacyViperEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", params.projectEnvValues ?? {}, ); @@ -66,7 +68,7 @@ export const legacyStreamPgDump = Effect.fnUntraced(function* <E>(params: { return yield* docker.runStream<E>( { - image: legacyGetRegistryImageUrl(params.image), + image: legacyGetRegistryImageUrl(params.image, params.projectEnvValues ?? {}), cmd: ["bash", "-c", params.script, "--"], env: params.env, binds: [], diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts index 03d014a8e6..ead62ce429 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.integration.test.ts @@ -1,6 +1,6 @@ import { createServer } from "node:net"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Cause, Effect, Exit, Layer } from "effect"; import { LegacyDebugFlag } from "../../shared/legacy/global-flags.ts"; import { legacyPgDeltaSslProbeLayer } from "./legacy-pgdelta-ssl-probe.layer.ts"; @@ -9,58 +9,70 @@ import { LegacyPgDeltaSslProbeError, } from "./legacy-pgdelta-ssl-probe.service.ts"; -async function withClosingServer<T>(run: (port: number) => Promise<T>): Promise<T> { - const server = createServer((socket) => { - socket.destroy(); - }); - - await new Promise<void>((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - - const address = server.address(); - if (address === null || typeof address === "string") { - server.close(); - throw new Error("failed to bind closing server"); - } - - try { - return await run(address.port); - } finally { - await new Promise<void>((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - } +function withClosingServer<A, E, R>(run: (port: number) => Effect.Effect<A, E, R>) { + return Effect.acquireUseRelease( + Effect.callback< + { readonly port: number; readonly server: ReturnType<typeof createServer> }, + Cause.UnknownError + >((resume) => { + const server = createServer((socket) => { + socket.destroy(); + }); + server.once("error", (error) => + resume(Effect.fail(new Cause.UnknownError(error, String(error)))), + ); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + resume(Effect.fail(new Cause.UnknownError(undefined, "failed to bind closing server"))); + } else { + resume(Effect.succeed({ port: address.port, server })); + } + }); + return Effect.sync(() => { + if (server.listening) server.close(); + }); + }), + ({ port }) => run(port), + ({ server }) => + Effect.callback<void, Cause.UnknownError>((resume) => { + server.close((error) => + error === undefined + ? resume(Effect.void) + : resume(Effect.fail(new Cause.UnknownError(error, String(error)))), + ); + }), + ); } describe("legacyPgDeltaSslProbeLayer", () => { it.live("fails promptly when the server disconnects before an SSL response byte", () => - Effect.tryPromise({ - try: () => - withClosingServer((port) => - Effect.runPromise( - Effect.gen(function* () { - const probe = yield* LegacyPgDeltaSslProbe; - const exit = yield* probe.requireSslForHost("127.0.0.1", port).pipe( - Effect.timeoutOrElse({ - duration: "1 second", - orElse: () => Effect.fail(new Error("probe did not settle after socket close")), - }), - Effect.exit, - ); + withClosingServer((port) => + Effect.gen(function* () { + const probe = yield* LegacyPgDeltaSslProbe; + const exit = yield* probe.requireSslForHost("127.0.0.1", port).pipe( + Effect.timeoutOrElse({ + duration: "1 second", + orElse: () => + Effect.fail( + new Cause.UnknownError( + undefined, + String("probe did not settle after socket close"), + ), + ), + }), + Effect.exit, + ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(String(exit.cause)).toContain(LegacyPgDeltaSslProbeError.name); - } - }).pipe( - Effect.provide(legacyPgDeltaSslProbeLayer), - Effect.provide(Layer.succeed(LegacyDebugFlag, false)), - ), - ), + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain(LegacyPgDeltaSslProbeError.name); + } + }).pipe( + Effect.provide( + Layer.mergeAll(legacyPgDeltaSslProbeLayer, Layer.succeed(LegacyDebugFlag, false)), ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }), + ), + ), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts index c91cbc1afe..742a76b3ff 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; @@ -84,52 +81,62 @@ const prepare = (cwd: string, ref: string, requireSsl: boolean | "error" = false describe("legacyPreparePgDeltaRef", () => { it.effect("passes through catalog-file refs without probing", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ssl-" }); const file = yield* prepare(dir, "supabase/.temp/pgdelta/catalog.json", "error"); expect(file).toEqual({ ref: "supabase/.temp/pgdelta/catalog.json", sslEnv: {} }); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("passes through a URL when the server refuses TLS (probe → not required)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ssl-" }); const local = yield* prepare(dir, "postgresql://u:p@127.0.0.1:54322/postgres", false); expect(local.ref).toBe("postgresql://u:p@127.0.0.1:54322/postgres"); expect(local.sslEnv).toEqual({}); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( "injects the CA bundle for a non-Supabase remote that requires TLS (probe → required)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ssl-" }); const prepared = yield* prepare(dir, "postgresql://u:p@db.example.com:5432/postgres", true); expect(prepared.ref).toContain("sslmode=verify-ca"); expect(prepared.ref).toContain("pgdelta-target-ca.crt"); expect(prepared.sslEnv[LEGACY_PG_DELTA_TARGET_SSL_ENV]).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect("propagates a probe connection error (Go's `return false, err`)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ssl-" }); const exit = yield* prepare( dir, "postgresql://u:p@db.example.com:5432/postgres", "error", ).pipe(Effect.exit); expect(exit._tag).toBe("Failure"); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect( "writes the CA bundle for a Supabase-hosted remote even when the probe reports no TLS", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ssl-")); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ssl-" }); // probe=false exercises Go's `pgDeltaRootCA` Supabase fallback branch. const prepared = yield* prepare( dir, @@ -143,12 +150,12 @@ describe("legacyPreparePgDeltaRef", () => { decodeURIComponent(new URL(prepared.ref).searchParams.get("sslrootcert") ?? ""), ).toBe("/workspace/supabase/.temp/pgdelta/pgdelta-target-ca.crt"); expect(prepared.sslEnv[LEGACY_PG_DELTA_TARGET_SSL_ENV]).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - const written = readFileSync( - join(dir, "supabase", ".temp", "pgdelta", "pgdelta-target-ca.crt"), - "utf8", + const written = yield* fs.readFileString( + path.join(dir, "supabase", ".temp", "pgdelta", "pgdelta-target-ca.crt"), ); expect(written).toBe(LEGACY_PG_DELTA_CA_BUNDLE); - }).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }, ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts index 051df9d57b..1c213d2128 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.cache.unit.test.ts @@ -1,15 +1,14 @@ import { createHash } from "node:crypto"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; +import { Clock, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { LegacyEdgeRuntimeScript } from "./legacy-edge-runtime-script.service.ts"; import { LegacyPgDeltaSslProbe } from "./legacy-pgdelta-ssl-probe.service.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; import { type LegacyPgDeltaContext } from "./legacy-pgdelta.ts"; import { LEGACY_NO_CACHE_BASELINE_CATALOG_NAME, @@ -134,15 +133,28 @@ describe("catalog keys + file names", () => { }); }); -const withTemp = () => mkdtempSync(join(tmpdir(), "legacy-decl-cache-")); +const tempRoot = useLegacyTempWorkdir("legacy-decl-cache-"); + +const writeFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + relativePath: string, + content: string, +) => { + const fullPath = path.join(workdir, relativePath); + return fs + .makeDirectory(path.dirname(fullPath), { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(fullPath, content))); +}; -const run = <A>(effect: Effect.Effect<A, unknown, FileSystem.FileSystem | Path.Path | Output>) => - effect.pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer)), - ) as Effect.Effect<A>; +const run = <A, E extends Error>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Output>, +): Effect.Effect<A, E> => + effect.pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer))); -const withServices = <A>( - body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, unknown, Output>, +const withServices = <A, E extends Error>( + body: (fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, E, Output>, ) => run( Effect.gen(function* () { @@ -154,19 +166,28 @@ const withServices = <A>( describe("legacyListLocalMigrations", () => { it.effect("returns sorted valid migrations, skipping a deprecated _init.sql first file", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* writeFile( + fs, + path, + dir, + "supabase/migrations/20200101000000_init.sql", + "-- old init", + ); + yield* writeFile( + fs, + path, + dir, + "supabase/migrations/20240101120000_create.sql", + "create table x();", + ); + yield* writeFile(fs, path, dir, "supabase/migrations/notes.txt", "ignore me"); + const paths = yield* legacyListLocalMigrations(fs, path, migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); + }), ); }); @@ -176,33 +197,37 @@ describe("legacyListLocalMigrations", () => { // Mirrors Go's `ListLocalMigrations` warnings (`pkg/migration/list.go:45-53`): // a `fmt.Fprintf(os.Stderr, …)` for the deprecated `_init.sql` first file and // for any name that does not match `<timestamp>_name.sql`. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20200101000000_init.sql"), "-- old init"); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsDir, "notes.txt"), "ignore me"); const out = mockOutput(); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* legacyListLocalMigrations(fs, path, migrationsDir); - }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); - const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); - expect(stderr).toContain( - 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', - ); - expect(stderr).toContain( - 'Skipping migration notes.txt... (file name must match pattern "<timestamp>_name.sql")\n', - ); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ) as Effect.Effect<unknown>; + const dir = tempRoot.current; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* writeFile( + fs, + path, + dir, + "supabase/migrations/20200101000000_init.sql", + "-- old init", + ); + yield* writeFile( + fs, + path, + dir, + "supabase/migrations/20240101120000_create.sql", + "create table x();", + ); + yield* writeFile(fs, path, dir, "supabase/migrations/notes.txt", "ignore me"); + const paths = yield* legacyListLocalMigrations(fs, path, migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual(["20240101120000_create.sql"]); + const stderr = out.rawChunks.filter((c) => c.stream === "stderr").map((c) => c.text); + expect(stderr).toContain( + 'Skipping migration 20200101000000_init.sql... (replace "init" with a different file name to apply this migration)\n', + ); + expect(stderr).toContain( + 'Skipping migration notes.txt... (file name must match pattern "<timestamp>_name.sql")\n', + ); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))); }, ); @@ -214,23 +239,26 @@ describe("legacyListLocalMigrations", () => { // whose target is a directory is NOT skipped as a directory — it is only ever dropped // later, if something actually tries to read it as a file. A naive `fs.stat`-based // directory check (which follows symlinks) would misclassify it and silently skip it. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const targetDir = join(dir, "outside-target"); - mkdirSync(targetDir, { recursive: true }); - writeFileSync(join(migrationsDir, "20240101120000_create.sql"), "create table x();"); - symlinkSync(targetDir, join(migrationsDir, "20240102000000_link.sql")); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - "20240101120000_create.sql", - "20240102000000_link.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const migrationsDir = path.join(dir, "supabase", "migrations"); + const targetDir = path.join(dir, "outside-target"); + yield* fs.makeDirectory(targetDir, { recursive: true }); + yield* writeFile( + fs, + path, + dir, + "supabase/migrations/20240101120000_create.sql", + "create table x();", + ); + yield* fs.symlink(targetDir, path.join(migrationsDir, "20240102000000_link.sql")); + const paths = yield* legacyListLocalMigrations(fs, path, migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual([ + "20240101120000_create.sql", + "20240102000000_link.sql", + ]); + }), ); }, ); @@ -245,36 +273,43 @@ describe("legacyListLocalMigrations", () => { // pair first (`0xD83D < 0xE000`), while Go's byte order — which preserves codepoint order — // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such filenames must // replay in Go's order, not JS's default, or a dependent migration could apply out of order. - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); const privateUseFile = "20240101120000_z\uE000.sql"; const supplementaryFile = "20240101120000_z\u{1F600}.sql"; - writeFileSync(join(migrationsDir, privateUseFile), "create table x();"); - writeFileSync(join(migrationsDir, supplementaryFile), "create table y();"); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, migrationsDir)).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths.map((p) => p.split("/").pop())).toEqual([ - privateUseFile, - supplementaryFile, - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* writeFile( + fs, + path, + dir, + `supabase/migrations/${privateUseFile}`, + "create table x();", + ); + yield* writeFile( + fs, + path, + dir, + `supabase/migrations/${supplementaryFile}`, + "create table y();", + ); + const paths = yield* legacyListLocalMigrations(fs, path, migrationsDir); + expect(paths.map((p) => p.split("/").pop())).toEqual([privateUseFile, supplementaryFile]); + }), ); }, ); it.effect("returns [] when the migrations dir is absent", () => { - const dir = withTemp(); - return withServices((fs, path) => legacyListLocalMigrations(fs, path, join(dir, "nope"))).pipe( - Effect.tap((paths) => - Effect.sync(() => { - expect(paths).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const paths = yield* legacyListLocalMigrations( + fs, + path, + path.join(tempRoot.current, "nope"), + ); + expect(paths).toEqual([]); + }), ); }); @@ -282,19 +317,15 @@ describe("legacyListLocalMigrations", () => { // `supabase/migrations` exists but is a file, not a directory — Go's // ListLocalMigrations aborts with `failed to read directory` rather than // treating it as "no migrations". - const dir = withTemp(); - const migrationsPath = join(dir, "supabase", "migrations"); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(migrationsPath, "not a directory"); return withServices((fs, path) => - legacyListLocalMigrations(fs, path, migrationsPath).pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(exit._tag).toBe("Failure"); - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.gen(function* () { + const dir = tempRoot.current; + const migrationsPath = path.join(dir, "supabase", "migrations"); + yield* fs.makeDirectory(path.join(dir, "supabase"), { recursive: true }); + yield* fs.writeFileString(migrationsPath, "not a directory"); + const exit = yield* legacyListLocalMigrations(fs, path, migrationsPath).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }), ); }); }); @@ -303,23 +334,24 @@ describe("legacyHashMigrations", () => { it.effect( "hashes the workdir-relative path + contents in list order (stable, content-sensitive)", () => { - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); - const file = join(migrationsDir, "20240101120000_create.sql"); - writeFileSync(file, "create table x();"); - const relPath = join("supabase", "migrations", "20240101120000_create.sql"); const expected = createHash("sha256") - .update(relPath, "utf8") + .update("supabase/migrations/20240101120000_create.sql", "utf8") .update(Buffer.from("create table x();")) .digest("hex"); - return withServices((fs, path) => legacyHashMigrations(fs, path, dir, migrationsDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const migrationsDir = path.join(dir, "supabase", "migrations"); + yield* writeFile( + fs, + path, + dir, + "supabase/migrations/20240101120000_create.sql", + "create table x();", + ); + const hash = yield* legacyHashMigrations(fs, path, dir, migrationsDir); + expect(hash).toBe(expected); + }), ); }, ); @@ -327,27 +359,32 @@ describe("legacyHashMigrations", () => { it.effect( "is unaffected by the absolute location of workdir (Go-parity, not machine-specific)", () => { - const dirA = withTemp(); - const dirB = withTemp(); - const migrationsA = join(dirA, "supabase", "migrations"); - const migrationsB = join(dirB, "supabase", "migrations"); - mkdirSync(migrationsA, { recursive: true }); - mkdirSync(migrationsB, { recursive: true }); - writeFileSync(join(migrationsA, "20240101120000_create.sql"), "create table x();"); - writeFileSync(join(migrationsB, "20240101120000_create.sql"), "create table x();"); return withServices((fs, path) => Effect.gen(function* () { + const dirA = yield* fs.makeTempDirectory({ prefix: "legacy-pgdelta-cache-a-" }); + const dirB = yield* fs.makeTempDirectory({ prefix: "legacy-pgdelta-cache-b-" }); + const migrationsA = path.join(dirA, "supabase", "migrations"); + const migrationsB = path.join(dirB, "supabase", "migrations"); + yield* writeFile( + fs, + path, + dirA, + "supabase/migrations/20240101120000_create.sql", + "create table x();", + ); + yield* writeFile( + fs, + path, + dirB, + "supabase/migrations/20240101120000_create.sql", + "create table x();", + ); const hashA = yield* legacyHashMigrations(fs, path, dirA, migrationsA); const hashB = yield* legacyHashMigrations(fs, path, dirB, migrationsB); expect(hashA).toBe(hashB); + yield* fs.remove(dirA, { recursive: true, force: true }); + yield* fs.remove(dirB, { recursive: true, force: true }); }), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - rmSync(dirA, { recursive: true, force: true }); - rmSync(dirB, { recursive: true, force: true }); - }), - ), ); }, ); @@ -355,25 +392,22 @@ describe("legacyHashMigrations", () => { describe("legacyHashDeclarativeSchemas", () => { it.effect("hashes forward-slash rel path + contents over sorted .sql files", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - writeFileSync(join(declDir, "nested", "auth.sql"), "B"); - writeFileSync(join(declDir, "skip.txt"), "C"); const expected = createHash("sha256") .update("nested/auth.sql", "utf8") .update(Buffer.from("B")) .update("public.sql", "utf8") .update(Buffer.from("A")) .digest("hex"); - return withServices((fs, path) => legacyHashDeclarativeSchemas(fs, path, declDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const declDir = path.join(dir, "supabase", "database"); + yield* writeFile(fs, path, dir, "supabase/database/public.sql", "A"); + yield* writeFile(fs, path, dir, "supabase/database/nested/auth.sql", "B"); + yield* writeFile(fs, path, dir, "supabase/database/skip.txt", "C"); + const hash = yield* legacyHashDeclarativeSchemas(fs, path, declDir); + expect(hash).toBe(expected); + }), ); }); @@ -381,49 +415,49 @@ describe("legacyHashDeclarativeSchemas", () => { // entries are excluded from the hash entirely — matching the walker's no-follow // semantics (codex review, PR #6162). it.effect("skips symlinked entries instead of following them", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - symlinkSync(join(dir, "supabase"), join(declDir, "loop")); const expected = createHash("sha256") .update("public.sql", "utf8") .update(Buffer.from("A")) .digest("hex"); - return withServices((fs, path) => legacyHashDeclarativeSchemas(fs, path, declDir)).pipe( - Effect.tap((hash) => - Effect.sync(() => { - expect(hash).toBe(expected); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const declDir = path.join(dir, "supabase", "database"); + yield* writeFile(fs, path, dir, "supabase/database/public.sql", "A"); + yield* fs.symlink(path.join(dir, "supabase"), path.join(declDir, "loop")); + const hash = yield* legacyHashDeclarativeSchemas(fs, path, declDir); + expect(hash).toBe(expected); + }), ); }); // Retention removal failures must propagate — a silently-failing cleanup would let // snapshots accumulate forever while every run reports success (codex review, PR #6162). it.effect("cleanup fails when an old snapshot cannot be removed", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 200, 300]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } return withServices((fs, path) => Effect.gen(function* () { - const err = yield* fs.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + for (const ts of [100, 200, 300]) { + yield* writeFile(fs, path, dir, `pgdelta/catalog-local-declarative-h-${ts}.json`, "{}"); + } + const err = PlatformError.systemError({ + module: "FileSystem", + method: "remove", + _tag: "PermissionDenied", + description: "permission denied", + pathOrDescriptor: path.join(dir, "pgdelta"), + }); const failing: FileSystem.FileSystem = { ...fs, remove: () => Effect.fail(err) }; - return yield* legacyCleanupOldDeclarativeCatalogs(failing, path, tempDir, "local").pipe( - Effect.exit, - ); + const exit = yield* legacyCleanupOldDeclarativeCatalogs( + failing, + path, + tempDir, + "local", + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); }), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), ); }); @@ -431,64 +465,59 @@ describe("legacyHashDeclarativeSchemas", () => { // than be treated as an empty tree — an empty-tree hash could cache an empty catalog // and let sync emit destructive drops (codex review, PR #6162). it.effect("fails when the root existence check itself fails", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(declDir, { recursive: true }); return withServices((fs, path) => Effect.gen(function* () { - const err = yield* fs.readDirectory(join(dir, "does-not-exist")).pipe(Effect.flip); + const dir = tempRoot.current; + const declDir = path.join(dir, "supabase", "database"); + yield* fs.makeDirectory(declDir, { recursive: true }); + const err = PlatformError.systemError({ + module: "FileSystem", + method: "exists", + _tag: "PermissionDenied", + description: "permission denied", + pathOrDescriptor: declDir, + }); const failing: FileSystem.FileSystem = { ...fs, exists: () => Effect.fail(err) }; - return yield* legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); + const exit = yield* legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); }), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), ); }); // A partial hash can collide with an existing cache key and serve a stale catalog, // so a traversal failure must fail the hash, not shrink it (codex review, PR #6162). it.effect("fails when part of the tree cannot be read instead of hashing a subset", () => { - const dir = withTemp(); - const declDir = join(dir, "supabase", "database"); - mkdirSync(join(declDir, "nested"), { recursive: true }); - writeFileSync(join(declDir, "public.sql"), "A"); - writeFileSync(join(declDir, "nested", "auth.sql"), "B"); - return withServices((fs, path) => { - const failing: FileSystem.FileSystem = { - ...fs, - readDirectory: (p, opts) => - p.endsWith("nested") - ? fs.readDirectory(join(dir, "does-not-exist")) - : fs.readDirectory(p, opts), - }; - return legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); - }).pipe( - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withServices((fs, path) => + Effect.gen(function* () { + const dir = tempRoot.current; + const declDir = path.join(dir, "supabase", "database"); + yield* writeFile(fs, path, dir, "supabase/database/public.sql", "A"); + yield* writeFile(fs, path, dir, "supabase/database/nested/auth.sql", "B"); + const failing: FileSystem.FileSystem = { + ...fs, + readDirectory: (p, opts) => + p.endsWith("nested") + ? fs.readDirectory(path.join(dir, "does-not-exist")) + : fs.readDirectory(p, opts), + }; + const exit = yield* legacyHashDeclarativeSchemas(failing, path, declDir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }), ); }); }); describe("legacyResolveDeclarativeCatalogPath + cleanup", () => { it.effect("resolves the newest snapshot and prunes to the retention count", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } - writeFileSync(join(tempDir, "catalog-local-declarative-other-50.json"), "{}"); return withServices((fs, path) => Effect.gen(function* () { + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + for (const ts of [100, 300, 200]) { + yield* writeFile(fs, path, dir, `pgdelta/catalog-local-declarative-h-${ts}.json`, "{}"); + } + yield* writeFile(fs, path, dir, "pgdelta/catalog-local-declarative-other-50.json", "{}"); const latest = yield* legacyResolveDeclarativeCatalogPath(fs, path, tempDir, "h", "local"); expect(Option.getOrNull(latest)?.endsWith("catalog-local-declarative-h-300.json")).toBe( true, @@ -502,7 +531,7 @@ describe("legacyResolveDeclarativeCatalogPath + cleanup", () => { "catalog-local-declarative-h-300.json", ]); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }); }); @@ -517,14 +546,14 @@ describe("legacyWriteDeclarativeCatalogSnapshot + cleanup", () => { it.effect( "writes the snapshot and prunes older declarative catalogs past the retention count", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-declarative-h-${ts}.json`), "{}"); - } return withServices((fs, path) => Effect.gen(function* () { + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + for (const ts of [100, 300, 200]) { + yield* writeFile(fs, path, dir, `pgdelta/catalog-local-declarative-h-${ts}.json`, "{}"); + } const filePath = yield* legacyWriteDeclarativeCatalogSnapshot( fs, path, @@ -544,19 +573,20 @@ describe("legacyWriteDeclarativeCatalogSnapshot + cleanup", () => { "catalog-local-declarative-h-400.json", ]); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }, ); it.effect("creates the temp dir when it doesn't exist yet", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); return withServices((fs, path) => Effect.gen(function* () { + const tempDir = path.join(tempRoot.current, "pgdelta"); yield* legacyWriteDeclarativeCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); - expect(yield* fs.exists(join(tempDir, "catalog-local-declarative-h-100.json"))).toBe(true); + expect(yield* fs.exists(path.join(tempDir, "catalog-local-declarative-h-100.json"))).toBe( + true, + ); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }); }); @@ -600,86 +630,80 @@ describe("legacyCatalogPrefixFromConfig", () => { describe("legacyResolveMigrationCatalogPath", () => { it.effect("resolves the newest snapshot for the (hash, prefix) family", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - // A different hash in the same prefix family must not be picked up. - writeFileSync(join(tempDir, "catalog-local-migrations-other-500.json"), "{}"); return withServices((fs, path) => Effect.gen(function* () { + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + for (const ts of [100, 300, 200]) { + yield* writeFile(fs, path, dir, `pgdelta/catalog-local-migrations-h-${ts}.json`, "{}"); + } + // A different hash in the same prefix family must not be picked up. + yield* writeFile(fs, path, dir, "pgdelta/catalog-local-migrations-other-500.json", "{}"); const latest = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, "h", "local"); expect(Option.getOrNull(latest)?.endsWith("catalog-local-migrations-h-300.json")).toBe( true, ); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }); it.effect("returns None on a cache miss (no matching family member)", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); return withServices((fs, path) => Effect.gen(function* () { - const resolved = yield* legacyResolveMigrationCatalogPath(fs, path, tempDir, "h", "local"); + const resolved = yield* legacyResolveMigrationCatalogPath( + fs, + path, + path.join(tempRoot.current, "pgdelta"), + "h", + "local", + ); expect(Option.isNone(resolved)).toBe(true); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }); }); describe("legacyResolveSetupInputs", () => { it.effect("resolves the image and tolerates a missing roles.sql", () => { - const dir = withTemp(); return withServices((fs, path) => - legacyResolveSetupInputs(fs, path, dir, 17, undefined, { - authEnabled: true, - storageEnabled: false, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.none(), - vaultNames: ["a_secret"], + Effect.gen(function* () { + const inputs = yield* legacyResolveSetupInputs(fs, path, tempRoot.current, 17, undefined, { + authEnabled: true, + storageEnabled: false, + realtimeEnabled: true, + apiAutoExposeNewTables: Option.none(), + vaultNames: ["a_secret"], + }); + expect(inputs).toMatchObject({ + majorVersion: 17, + authEnabled: true, + storageEnabled: false, + realtimeEnabled: true, + autoExpose: false, + vaultNames: ["a_secret"], + rolesSql: "", + }); + expect(inputs.image.length).toBeGreaterThan(0); }), - ).pipe( - Effect.tap((inputs) => - Effect.sync(() => { - expect(inputs).toMatchObject({ - majorVersion: 17, - authEnabled: true, - storageEnabled: false, - realtimeEnabled: true, - autoExpose: false, - vaultNames: ["a_secret"], - rolesSql: "", - }); - expect(inputs.image.length).toBeGreaterThan(0); - rmSync(dir, { recursive: true, force: true }); - }), - ), ); }); it.effect("reads roles.sql content and resolves the effective auto-expose bool", () => { - const dir = withTemp(); - mkdirSync(join(dir, "supabase"), { recursive: true }); - writeFileSync(join(dir, "supabase", "roles.sql"), "create role app;"); return withServices((fs, path) => - legacyResolveSetupInputs(fs, path, dir, 17, undefined, { - authEnabled: true, - storageEnabled: true, - realtimeEnabled: true, - apiAutoExposeNewTables: Option.some(true), - vaultNames: [], + Effect.gen(function* () { + const dir = tempRoot.current; + yield* writeFile(fs, path, dir, "supabase/roles.sql", "create role app;"); + const inputs = yield* legacyResolveSetupInputs(fs, path, dir, 17, undefined, { + authEnabled: true, + storageEnabled: true, + realtimeEnabled: true, + apiAutoExposeNewTables: Option.some(true), + vaultNames: [], + }); + expect(inputs.rolesSql).toBe("create role app;"); + expect(inputs.autoExpose).toBe(true); }), - ).pipe( - Effect.tap((inputs) => - Effect.sync(() => { - expect(inputs.rolesSql).toBe("create role app;"); - expect(inputs.autoExpose).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), ); }); }); @@ -696,14 +720,14 @@ describe("legacyWriteMigrationCatalogSnapshot + cleanup", () => { it.effect( "writes the snapshot and prunes older migrations catalogs past the retention count", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 300, 200]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } return withServices((fs, path) => Effect.gen(function* () { + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + for (const ts of [100, 300, 200]) { + yield* writeFile(fs, path, dir, `pgdelta/catalog-local-migrations-h-${ts}.json`, "{}"); + } const filePath = yield* legacyWriteMigrationCatalogSnapshot( fs, path, @@ -723,19 +747,20 @@ describe("legacyWriteMigrationCatalogSnapshot + cleanup", () => { "catalog-local-migrations-h-400.json", ]); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }, ); it.effect("creates the temp dir when it doesn't exist yet", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); return withServices((fs, path) => Effect.gen(function* () { + const tempDir = path.join(tempRoot.current, "pgdelta"); yield* legacyWriteMigrationCatalogSnapshot(fs, path, tempDir, "local", "h", "{}", 100); - expect(yield* fs.exists(join(tempDir, "catalog-local-migrations-h-100.json"))).toBe(true); + expect(yield* fs.exists(path.join(tempDir, "catalog-local-migrations-h-100.json"))).toBe( + true, + ); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }); }); @@ -755,12 +780,7 @@ describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-195 // AFTER that sleep, proving the clock was read after the export — not // captured up front by a caller before this function even started (the // pre-fix bug). - const dir = withTemp(); - const migrationsDir = join(dir, "supabase", "migrations"); - mkdirSync(migrationsDir, { recursive: true }); // Mirrors `legacyPgDeltaTempPath` (`<workdir>/supabase/.temp/pgdelta`). - const tempDir = join(dir, "supabase", ".temp", "pgdelta"); - const beforeCallMillis = Date.now(); const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: () => Effect.gen(function* () { @@ -774,7 +794,7 @@ describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-195 }); const ctx: LegacyPgDeltaContext = { projectId: "test", - cwd: dir, + cwd: tempRoot.current, npmVersion: undefined, denoVersion: 1, projectEnv: {}, @@ -782,6 +802,11 @@ describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-195 return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const dir = tempRoot.current; + const migrationsDir = path.join(dir, "supabase", "migrations"); + const tempDir = path.join(dir, "supabase", ".temp", "pgdelta"); + yield* fs.makeDirectory(migrationsDir, { recursive: true }); + const beforeCallMillis = yield* Clock.currentTimeMillis; yield* legacyTryCacheMigrationsCatalog(fs, path, ctx, { enabled: true, targetUrl: "postgresql://postgres:postgres@127.0.0.1:5432/postgres", @@ -798,8 +823,15 @@ describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-195 const embeddedMillis = Number(match![1]); expect(embeddedMillis).toBeGreaterThanOrEqual(beforeCallMillis + 25); }).pipe( - Effect.provide(Layer.mergeAll(BunServices.layer, mockOutput().layer, edge, sslProbe)), - Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + Effect.provide( + Layer.mergeAll( + BunServices.layer, + mockOutput().layer, + edge, + sslProbe, + makeLegacyViperEnvLayer(), + ), + ), ); }, ); @@ -807,15 +839,15 @@ describe("legacyTryCacheMigrationsCatalog — timestamp ordering (review CLI-195 describe("legacyCleanupOldMigrationCatalogs", () => { it.effect("only prunes files matching the given prefix's family", () => { - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - for (const ts of [100, 200, 300]) { - writeFileSync(join(tempDir, `catalog-local-migrations-h-${ts}.json`), "{}"); - } - writeFileSync(join(tempDir, "catalog-other-migrations-h-50.json"), "{}"); return withServices((fs, path) => Effect.gen(function* () { + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + for (const ts of [100, 200, 300]) { + yield* writeFile(fs, path, dir, `pgdelta/catalog-local-migrations-h-${ts}.json`, "{}"); + } + yield* writeFile(fs, path, dir, "pgdelta/catalog-other-migrations-h-50.json", "{}"); yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local"); const remaining = (yield* fs.readDirectory(tempDir)).sort(); expect(remaining).toEqual([ @@ -824,7 +856,7 @@ describe("legacyCleanupOldMigrationCatalogs", () => { "catalog-other-migrations-h-50.json", ]); }), - ).pipe(Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true })))); + ); }); it.effect( @@ -836,21 +868,18 @@ describe("legacyCleanupOldMigrationCatalogs", () => { // than silently look like "no cached catalogs" (which would bypass retention // indefinitely, since the caller's own best-effort warning never fires without // a propagated failure). - const dir = withTemp(); - const tempDir = join(dir, "pgdelta"); - mkdirSync(tempDir, { recursive: true }); - writeFileSync(join(tempDir, "catalog-local-migrations-h-100.json"), "{}"); - chmodSync(tempDir, 0o000); return withServices((fs, path) => - legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local").pipe(Effect.exit), - ).pipe( - Effect.tap((exit) => - Effect.sync(() => { - chmodSync(tempDir, 0o755); - expect(Exit.isFailure(exit)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), + Effect.gen(function* () { + const dir = tempRoot.current; + const tempDir = path.join(dir, "pgdelta"); + yield* writeFile(fs, path, dir, "pgdelta/catalog-local-migrations-h-100.json", "{}"); + yield* fs.chmod(tempDir, 0o000); + const exit = yield* legacyCleanupOldMigrationCatalogs(fs, path, tempDir, "local").pipe( + Effect.exit, + ); + yield* fs.chmod(tempDir, 0o755); + expect(Exit.isFailure(exit)).toBe(true); + }), ); }, ); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts index 123246afe9..90a72e11ef 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Cause, Effect, Exit, Layer } from "effect"; +import { Cause, ConfigProvider, Effect, Exit, Layer } from "effect"; import { type LegacyEdgeRuntimeRunOpts, @@ -19,6 +19,11 @@ import { legacyExportCatalogPgDelta, type LegacyPgDeltaContext, } from "./legacy-pgdelta.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; + +const legacyViperEnvLayer = makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), +); const CTX: LegacyPgDeltaContext = { projectId: "ref", @@ -123,7 +128,7 @@ describe("legacyDiffPgDelta", () => { ]); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }, ); @@ -144,7 +149,7 @@ describe("legacyDiffPgDelta", () => { expect(env["FORMAT_OPTIONS"]).toBeUndefined(); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -165,7 +170,7 @@ describe("legacyDiffPgDelta", () => { ); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -189,7 +194,7 @@ describe("legacyDiffPgDelta", () => { }); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -210,7 +215,7 @@ describe("legacyDiffPgDelta", () => { expect(message).toContain("boom"); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -243,7 +248,7 @@ describe("legacyDiffPgDelta", () => { ); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); }); @@ -269,7 +274,7 @@ describe("legacyDeclarativeExportPgDelta", () => { expect(edge.calls[0]!.errPrefix).toBe("error exporting declarative schema"); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -290,7 +295,7 @@ describe("legacyDeclarativeExportPgDelta", () => { ); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -311,7 +316,7 @@ describe("legacyDeclarativeExportPgDelta", () => { ); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); }); @@ -332,7 +337,7 @@ describe("legacyExportCatalogPgDelta", () => { expect(opts.env["ROLE"]).toBe("postgres"); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); @@ -345,7 +350,7 @@ describe("legacyExportCatalogPgDelta", () => { expect(failError(exit)?.constructor.name).toBe("LegacyDeclarativeEmptyOutputError"); }), ), - Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer)), + Effect.provide(Layer.mergeAll(edge.layer, probe, BunServices.layer, legacyViperEnvLayer)), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.ts index 71e0321a3a..7dcf6ef5ca 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { legacyViperEnvStringWithProjectFallback } from "../../shared/legacy/legacy-viper-env.ts"; import { @@ -42,6 +42,21 @@ export interface LegacyDeclarativeOutput { readonly files: ReadonlyArray<LegacyDeclarativeFile>; } +const LegacyDeclarativeOutputJsonSchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Finite, + mode: Schema.String, + files: Schema.Array( + Schema.Struct({ + path: Schema.String, + order: Schema.Finite, + statements: Schema.Finite, + sql: Schema.String, + }), + ), + }), +); + /** * One execution-aware migration unit from a pg-delta diff plan. Mirrors Go's * `PgDeltaPlanFile` (`internal/db/diff/pgdelta.go`): a numbered SQL file whose @@ -55,14 +70,19 @@ interface LegacyPgDeltaPlanFile { } /** The pg-delta diff envelope. Mirrors Go's `PgDeltaDiffOutput`. */ -interface LegacyPgDeltaDiffOutput { - readonly version: number; - readonly files: ReadonlyArray< - Omit<LegacyPgDeltaPlanFile, "transactionMode"> & { - readonly transactionMode: string; - } - >; -} +const LegacyPgDeltaDiffOutputJsonSchema = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Finite, + files: Schema.Array( + Schema.Struct({ + order: Schema.Finite, + name: Schema.String, + transactionMode: Schema.String, + sql: Schema.String, + }), + ), + }), +); /** * Result of a pg-delta diff: the per-unit plan `files`, a `sql` flattening of @@ -168,8 +188,8 @@ export function legacyPgDeltaBinds(projectId: string, cwd: string): ReadonlyArra } /** Mirrors Go's `IsPgDeltaDebugEnabled` (`internal/db/diff/pgdelta_debug.go:11`). */ -export function legacyIsPgDeltaDebugEnabled(): boolean { - const value = (process.env["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); +export function legacyIsPgDeltaDebugEnabled(projectEnv: Readonly<Record<string, string>>): boolean { + const value = (projectEnv["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; } @@ -189,20 +209,22 @@ export function legacyIsPgDeltaDebugEnabled(): boolean { * call in Go. `projectEnv` reproduces that merge with the same shell-presence-wins semantics * (review: PRRT_kwDOErm0O86XFmjf). */ -export function legacyPgDeltaNpmRegistryOption(projectEnv: Readonly<Record<string, string>>): { - readonly extraFiles?: ReadonlyArray<LegacyEdgeRuntimeFile>; - readonly extraEnv?: Readonly<Record<string, string>>; -} { - const registry = legacyViperEnvStringWithProjectFallback( +export const legacyPgDeltaNpmRegistryOption = Effect.fnUntraced(function* ( + projectEnv: Readonly<Record<string, string>>, +) { + const registry = (yield* legacyViperEnvStringWithProjectFallback( PG_DELTA_NPM_REGISTRY_ENV, projectEnv, - ).trim(); + )).trim(); if (registry.length === 0) return {}; return { extraFiles: [{ name: ".npmrc", content: `@supabase:registry=${registry}\n` }], extraEnv: { [PG_DELTA_NPM_REGISTRY_ENV]: registry, NPM_CONFIG_REGISTRY: registry }, + } satisfies { + readonly extraFiles?: ReadonlyArray<LegacyEdgeRuntimeFile>; + readonly extraEnv?: Readonly<Record<string, string>>; }; -} +}); /** Adds the container ref + any SSL env for a SOURCE/TARGET endpoint (writes a CA bundle for Supabase-hosted remotes). */ const appendRefEnv = Effect.fnUntraced(function* ( @@ -225,6 +247,7 @@ const buildDiffEnv = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, cwd: string, + projectEnv: Readonly<Record<string, string>>, params: { readonly targetRef: string; readonly sourceRef: string; @@ -238,7 +261,7 @@ const buildDiffEnv = Effect.fnUntraced(function* ( yield* appendRefEnv(fs, path, cwd, env, "SOURCE", params.sourceRef); if (params.schema.length > 0) env["INCLUDED_SCHEMAS"] = params.schema.join(","); if (params.formatOptions.trim().length > 0) env["FORMAT_OPTIONS"] = params.formatOptions; - if (legacyIsPgDeltaDebugEnabled()) env["PGDELTA_DEBUG"] = "1"; + if (legacyIsPgDeltaDebugEnabled(projectEnv)) env["PGDELTA_DEBUG"] = "1"; return env; }); @@ -269,8 +292,8 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( const edgeRuntime = yield* LegacyEdgeRuntimeScript; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); + const env = yield* buildDiffEnv(fs, path, ctx.cwd, ctx.projectEnv, params); + const npm = yield* legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDiffScript, ctx.npmVersion), @@ -279,6 +302,7 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( errPrefix: "error diffing schema", extraFiles: npm.extraFiles, extraEnv: npm.extraEnv, + projectEnvValues: ctx.projectEnv, denoVersion: ctx.denoVersion, workdir: ctx.cwd, }) @@ -290,25 +314,24 @@ export const legacyDiffPgDelta = Effect.fnUntraced(function* ( if (result.stdout.trim().length === 0) { return { sql: "", files: [], stderr: result.stderr } satisfies LegacyPgDeltaDiffResult; } - const envelope = yield* Effect.try({ - try: () => JSON.parse(result.stdout) as LegacyPgDeltaDiffOutput, - catch: (cause) => - new LegacyPgDeltaDiffParseError({ - message: `failed to parse pg-delta diff output: ${ - cause instanceof Error ? cause.message : String(cause) - }:\n${result.stderr}`, - }), - }); + const envelope = yield* Schema.decodeEffect(LegacyPgDeltaDiffOutputJsonSchema)( + result.stdout, + ).pipe( + Effect.mapError( + (cause) => + new LegacyPgDeltaDiffParseError({ + message: `failed to parse pg-delta diff output: ${String(cause)}:\n${result.stderr}`, + }), + ), + ); const rawFiles = envelope.files ?? []; const files: Array<LegacyPgDeltaPlanFile> = []; for (const file of rawFiles) { const transactionMode = file.transactionMode; if (transactionMode !== "transactional" && transactionMode !== "none") { - return yield* Effect.fail( - new LegacyPgDeltaDiffParseError({ - message: `unknown pg-delta transaction mode ${JSON.stringify(transactionMode)}`, - }), - ); + return yield* new LegacyPgDeltaDiffParseError({ + message: `unknown pg-delta transaction mode "${transactionMode}"`, + }); } files.push({ ...file, transactionMode }); } @@ -335,8 +358,8 @@ export const legacyDeclarativeExportPgDelta = Effect.fnUntraced(function* ( const edgeRuntime = yield* LegacyEdgeRuntimeScript; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const env = yield* buildDiffEnv(fs, path, ctx.cwd, params); - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); + const env = yield* buildDiffEnv(fs, path, ctx.cwd, ctx.projectEnv, params); + const npm = yield* legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaDeclarativeExportScript, ctx.npmVersion), @@ -345,28 +368,26 @@ export const legacyDeclarativeExportPgDelta = Effect.fnUntraced(function* ( errPrefix: "error exporting declarative schema", extraFiles: npm.extraFiles, extraEnv: npm.extraEnv, + projectEnvValues: ctx.projectEnv, denoVersion: ctx.denoVersion, workdir: ctx.cwd, }) .pipe(Effect.mapError(toDeclarativeEdgeRuntimeError)); if (result.stdout.length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeEmptyOutputError({ - message: `error exporting declarative schema: edge-runtime script produced no output:\n${result.stderr}`, - }), - ); + return yield* new LegacyDeclarativeEmptyOutputError({ + message: `error exporting declarative schema: edge-runtime script produced no output:\n${result.stderr}`, + }); } - return yield* Effect.try({ - try: () => JSON.parse(result.stdout) as LegacyDeclarativeOutput, - catch: (cause) => - new LegacyDeclarativeParseOutputError({ - message: `failed to parse declarative export output: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - }), - }); + return yield* Schema.decodeEffect(LegacyDeclarativeOutputJsonSchema)(result.stdout).pipe( + Effect.mapError( + (cause) => + new LegacyDeclarativeParseOutputError({ + message: `failed to parse declarative export output: ${String(cause)}`, + }), + ), + ); }); /** @@ -385,7 +406,7 @@ export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( const env: Record<string, string> = {}; yield* appendRefEnv(fs, path, ctx.cwd, env, "TARGET", params.targetRef); if (params.role.length > 0) env["ROLE"] = params.role; - const npm = legacyPgDeltaNpmRegistryOption(ctx.projectEnv); + const npm = yield* legacyPgDeltaNpmRegistryOption(ctx.projectEnv); const result = yield* edgeRuntime .run({ script: legacyInterpolatePgDeltaScript(legacyPgDeltaCatalogExportScript, ctx.npmVersion), @@ -394,6 +415,7 @@ export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( errPrefix: "error exporting pg-delta catalog", extraFiles: npm.extraFiles, extraEnv: npm.extraEnv, + projectEnvValues: ctx.projectEnv, denoVersion: ctx.denoVersion, workdir: ctx.cwd, }) @@ -401,11 +423,9 @@ export const legacyExportCatalogPgDelta = Effect.fnUntraced(function* ( const snapshot = result.stdout.trim(); if (snapshot.length === 0) { - return yield* Effect.fail( - new LegacyDeclarativeEmptyOutputError({ - message: `error exporting pg-delta catalog: edge-runtime script produced no output:\n${result.stderr}`, - }), - ); + return yield* new LegacyDeclarativeEmptyOutputError({ + message: `error exporting pg-delta catalog: edge-runtime script produced no output:\n${result.stderr}`, + }); } return snapshot; }); diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts index f012934ce2..71611c4939 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta.unit.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { ConfigProvider, Effect } from "effect"; +import { describe, expect, it } from "@effect/vitest"; import { legacyEdgeRuntimeId, @@ -8,6 +9,7 @@ import { legacyPgDeltaContainerRef, legacyPgDeltaNpmRegistryOption, } from "./legacy-pgdelta.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; describe("legacyIsPostgresURL", () => { it("recognizes postgres:// and postgresql:// schemes", () => { @@ -55,63 +57,66 @@ describe("legacyPgDeltaBinds", () => { }); describe("legacyIsPgDeltaDebugEnabled", () => { - const prev = process.env["PGDELTA_DEBUG"]; - afterEach(() => { - if (prev === undefined) delete process.env["PGDELTA_DEBUG"]; - else process.env["PGDELTA_DEBUG"] = prev; - }); - it("is true for 1/true/yes (case-insensitive, trimmed)", () => { for (const value of ["1", "true", "YES", " True "]) { - process.env["PGDELTA_DEBUG"] = value; - expect(legacyIsPgDeltaDebugEnabled()).toBe(true); + expect(legacyIsPgDeltaDebugEnabled({ PGDELTA_DEBUG: value })).toBe(true); } }); it("is false otherwise", () => { - process.env["PGDELTA_DEBUG"] = "0"; - expect(legacyIsPgDeltaDebugEnabled()).toBe(false); - delete process.env["PGDELTA_DEBUG"]; - expect(legacyIsPgDeltaDebugEnabled()).toBe(false); + expect(legacyIsPgDeltaDebugEnabled({ PGDELTA_DEBUG: "0" })).toBe(false); + expect(legacyIsPgDeltaDebugEnabled({})).toBe(false); }); }); describe("legacyPgDeltaNpmRegistryOption", () => { - const prev = process.env["PGDELTA_NPM_REGISTRY"]; - afterEach(() => { - if (prev === undefined) delete process.env["PGDELTA_NPM_REGISTRY"]; - else process.env["PGDELTA_NPM_REGISTRY"] = prev; - }); + const resolve = (projectEnv: Record<string, string>, shellEnv: Record<string, string> = {}) => + legacyPgDeltaNpmRegistryOption(projectEnv).pipe( + Effect.provide( + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: shellEnv, preserveEmptyStrings: true }), + ), + ), + ); - it("returns no option when unset in both the shell and the project .env", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - expect(legacyPgDeltaNpmRegistryOption({})).toEqual({}); - }); + it.effect("returns no option when unset in both the shell and the project .env", () => + resolve({}).pipe(Effect.map((result) => expect(result).toEqual({}))), + ); - it("falls back to the project .env when the shell env is unset (Go's godotenv.Load parity)", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - const npm = legacyPgDeltaNpmRegistryOption({ - PGDELTA_NPM_REGISTRY: "https://registry.example.com", - }); - expect(npm.extraFiles).toEqual([ - { name: ".npmrc", content: "@supabase:registry=https://registry.example.com\n" }, - ]); - expect(npm.extraEnv).toEqual({ - PGDELTA_NPM_REGISTRY: "https://registry.example.com", - NPM_CONFIG_REGISTRY: "https://registry.example.com", - }); - }); + it.effect( + "falls back to the project .env when the shell env is unset (Go's godotenv.Load parity)", + () => + resolve({ PGDELTA_NPM_REGISTRY: "https://registry.example.com" }).pipe( + Effect.tap((npm) => + Effect.sync(() => { + expect(npm.extraFiles).toEqual([ + { name: ".npmrc", content: "@supabase:registry=https://registry.example.com\n" }, + ]); + expect(npm.extraEnv).toEqual({ + PGDELTA_NPM_REGISTRY: "https://registry.example.com", + NPM_CONFIG_REGISTRY: "https://registry.example.com", + }); + }), + ), + ), + ); - it("prefers the shell env over the project .env (shell presence wins)", () => { - process.env["PGDELTA_NPM_REGISTRY"] = "https://shell.example.com"; - const npm = legacyPgDeltaNpmRegistryOption({ - PGDELTA_NPM_REGISTRY: "https://dotenv.example.com", - }); - expect(npm.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe("https://shell.example.com"); - }); + it.effect("prefers the shell env over the project .env (shell presence wins)", () => + resolve( + { PGDELTA_NPM_REGISTRY: "https://dotenv.example.com" }, + { PGDELTA_NPM_REGISTRY: "https://shell.example.com" }, + ).pipe( + Effect.tap((npm) => + Effect.sync(() => { + expect(npm.extraEnv?.["PGDELTA_NPM_REGISTRY"]).toBe("https://shell.example.com"); + }), + ), + ), + ); - it("treats a whitespace-only value as unset", () => { - delete process.env["PGDELTA_NPM_REGISTRY"]; - expect(legacyPgDeltaNpmRegistryOption({ PGDELTA_NPM_REGISTRY: " " })).toEqual({}); - }); + it.effect("treats a whitespace-only value as unset", () => + resolve({ PGDELTA_NPM_REGISTRY: " " }).pipe( + Effect.map((result) => expect(result).toEqual({})), + ), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pgpass.ts b/apps/cli/src/legacy/shared/legacy-pgpass.ts index 8773750b48..2323316af9 100644 --- a/apps/cli/src/legacy/shared/legacy-pgpass.ts +++ b/apps/cli/src/legacy/shared/legacy-pgpass.ts @@ -1,7 +1,3 @@ -import { readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - /** * libpq `.pgpass` password lookup, a 1:1 port of `jackc/pgpassfile` * (`ParsePassfile` + `FindPassword`) as used by `pgconn.ParseConfig`: @@ -76,9 +72,15 @@ export function legacyFindPgpassPassword( return ""; } -/** Environment lookup for `PGPASSFILE`/`APPDATA`; defaults to `process.env`. */ +/** Environment lookup for `PGPASSFILE`/`APPDATA`, supplied by the caller. */ type LegacyPassfileEnv = (name: string) => string | undefined; -const processEnv: LegacyPassfileEnv = (name) => process.env[name]; + +/** Explicit filesystem/path data supplied by the Effect composition boundary. */ +export interface LegacyPgpassRuntime { + readonly homeDirectory?: string; + readonly join: (base: string, ...parts: ReadonlyArray<string>) => string; + readonly files: ReadonlyMap<string, string>; +} /** * Resolve the passfile path with pgconn's precedence: an @@ -91,7 +93,11 @@ const processEnv: LegacyPassfileEnv = (name) => process.env[name]; * `os.Open("")` fails → no `.pgpass` lookup → empty password). Only an *absent* * (`undefined`) setting falls through to `PGPASSFILE`/the default. */ -function pgpassFilePath(env: LegacyPassfileEnv, passfile: string | undefined): string | undefined { +function pgpassFilePath( + env: LegacyPassfileEnv, + passfile: string | undefined, + runtime: LegacyPgpassRuntime | undefined, +): string | undefined { if (passfile !== undefined) { return passfile.length > 0 ? passfile : undefined; } @@ -102,11 +108,13 @@ function pgpassFilePath(env: LegacyPassfileEnv, passfile: string | undefined): s if (process.platform === "win32") { const appData = env("APPDATA"); return appData !== undefined && appData.length > 0 - ? join(appData, "postgresql", "pgpass.conf") + ? runtime?.join(appData, "postgresql", "pgpass.conf") : undefined; } - const home = homedir(); - return home.length > 0 ? join(home, ".pgpass") : undefined; + const home = runtime?.homeDirectory; + return home !== undefined && home.length > 0 && runtime !== undefined + ? runtime.join(home, ".pgpass") + : undefined; } /** @@ -114,27 +122,26 @@ function pgpassFilePath(env: LegacyPassfileEnv, passfile: string | undefined): s * when the file is absent/unreadable or has no matching entry. A unix-socket * host (a path) matches `localhost`, mirroring pgconn's `NetworkAddress`. * - * `env` supplies `PGPASSFILE`/`APPDATA` (defaults to `process.env`); `passfile` is - * an explicit connection-string `passfile=` setting that takes precedence. + * `env` supplies `PGPASSFILE`/`APPDATA`; `passfile` is an explicit + * connection-string `passfile=` setting that takes precedence. The runtime's + * file map is populated by the Effect filesystem boundary; this parser never + * reads process-global state or performs synchronous I/O. */ export function legacyPgpassPassword( host: string, port: number, database: string, username: string, - env: LegacyPassfileEnv = processEnv, + env: LegacyPassfileEnv = () => undefined, passfile?: string, + runtime?: LegacyPgpassRuntime, ): string { - const path = pgpassFilePath(env, passfile); + const path = pgpassFilePath(env, passfile, runtime); if (path === undefined) { return ""; } - let contents: string; - try { - contents = readFileSync(path, "utf8"); - } catch { - return ""; - } + const contents = runtime?.files.get(path); + if (contents === undefined) return ""; const matchHost = host.startsWith("/") ? "localhost" : host; return legacyFindPgpassPassword(contents, matchHost, String(port), database, username); } diff --git a/apps/cli/src/legacy/shared/legacy-pgpass.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgpass.unit.test.ts index 040ca6e2ca..dc567e348b 100644 --- a/apps/cli/src/legacy/shared/legacy-pgpass.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgpass.unit.test.ts @@ -1,7 +1,6 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { legacyFindPgpassPassword, legacyPgpassPassword } from "./legacy-pgpass.ts"; @@ -39,34 +38,71 @@ describe("legacyFindPgpassPassword", () => { }); describe("legacyPgpassPassword (passfile + injected env precedence)", () => { - let tmp: string; - let explicitPath: string; - let envPath: string; + const fixture = ( + run: ( + tmp: string, + explicitPath: string, + envPath: string, + files: ReadonlyMap<string, string>, + ) => Effect.Effect<void>, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmp = yield* fs.makeTempDirectory({ prefix: "pgpass-fn-" }); + const explicitPath = path.join(tmp, "explicit"); + const envPath = path.join(tmp, "env"); + yield* fs.writeFileString(explicitPath, "h:5432:d:u:explicit-secret\n"); + yield* fs.writeFileString(envPath, "h:5432:d:u:env-secret\n"); + const files = new Map([ + [explicitPath, yield* fs.readFileString(explicitPath)], + [envPath, yield* fs.readFileString(envPath)], + ]); + yield* run(tmp, explicitPath, envPath, files); + yield* fs.remove(tmp, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pgpass-fn-")); - explicitPath = join(tmp, "explicit"); - envPath = join(tmp, "env"); - writeFileSync(explicitPath, "h:5432:d:u:explicit-secret\n"); - writeFileSync(envPath, "h:5432:d:u:env-secret\n"); - }); - - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); + it.effect("prefers an explicit passfile over PGPASSFILE from the injected env", () => + fixture((_tmp, explicitPath, envPath, files) => { + const env = (name: string): string | undefined => + name === "PGPASSFILE" ? envPath : undefined; + return Effect.sync(() => + expect( + legacyPgpassPassword("h", 5432, "d", "u", env, explicitPath, { + join: (base, ...parts) => [base, ...parts].join("/"), + files, + }), + ).toBe("explicit-secret"), + ); + }), + ); - it("prefers an explicit passfile over PGPASSFILE from the injected env", () => { - const env = (name: string): string | undefined => (name === "PGPASSFILE" ? envPath : undefined); - expect(legacyPgpassPassword("h", 5432, "d", "u", env, explicitPath)).toBe("explicit-secret"); - }); + it.effect("falls back to PGPASSFILE from the injected env when no explicit passfile", () => + fixture((_tmp, _explicitPath, envPath, files) => { + const env = (name: string): string | undefined => + name === "PGPASSFILE" ? envPath : undefined; + return Effect.sync(() => + expect( + legacyPgpassPassword("h", 5432, "d", "u", env, undefined, { + join: (base, ...parts) => [base, ...parts].join("/"), + files, + }), + ).toBe("env-secret"), + ); + }), + ); - it("falls back to PGPASSFILE from the injected env when no explicit passfile", () => { - const env = (name: string): string | undefined => (name === "PGPASSFILE" ? envPath : undefined); - expect(legacyPgpassPassword("h", 5432, "d", "u", env)).toBe("env-secret"); - }); - - it("returns empty string when the resolved passfile is unreadable", () => { - const env = (): string | undefined => undefined; - expect(legacyPgpassPassword("h", 5432, "d", "u", env, join(tmp, "missing"))).toBe(""); - }); + it.effect("returns empty string when the resolved passfile is unreadable", () => + fixture((tmp, _explicitPath, _envPath, files) => { + const env = (): string | undefined => undefined; + return Effect.sync(() => + expect( + legacyPgpassPassword("h", 5432, "d", "u", env, `${tmp}/missing`, { + join: (base, ...parts) => [base, ...parts].join("/"), + files, + }), + ).toBe(""), + ); + }), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pgservicefile.ts b/apps/cli/src/legacy/shared/legacy-pgservicefile.ts index ab88d21670..63d19668e1 100644 --- a/apps/cli/src/legacy/shared/legacy-pgservicefile.ts +++ b/apps/cli/src/legacy/shared/legacy-pgservicefile.ts @@ -1,5 +1,3 @@ -import { readFileSync } from "node:fs"; - /** * PostgreSQL service file (`pg_service.conf`) support, a 1:1 port of * `jackc/pgservicefile` as used by `pgconn.ParseConfig`: @@ -53,13 +51,10 @@ export function parseLegacyServicefile(contents: string): Map<string, Map<string export function legacyServiceSettings( serviceName: string, servicefilePath: string, + files: ReadonlyMap<string, string> = new Map(), ): Map<string, string> | undefined { - let contents: string; - try { - contents = readFileSync(servicefilePath, "utf8"); - } catch { - return undefined; - } + const contents = files.get(servicefilePath); + if (contents === undefined) return undefined; let services: Map<string, Map<string, string>>; try { services = parseLegacyServicefile(contents); diff --git a/apps/cli/src/legacy/shared/legacy-pgservicefile.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pgservicefile.unit.test.ts index 6452796adf..be09617818 100644 --- a/apps/cli/src/legacy/shared/legacy-pgservicefile.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pgservicefile.unit.test.ts @@ -1,7 +1,6 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { legacyServiceSettings, parseLegacyServicefile } from "./legacy-pgservicefile.ts"; @@ -41,40 +40,63 @@ describe("parseLegacyServicefile", () => { }); describe("legacyServiceSettings", () => { - let tmp: string; - let path: string; + const fixture = ( + run: ( + tmp: string, + servicePath: string, + files: ReadonlyMap<string, string>, + ) => Effect.Effect<void>, + ) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tmp = yield* fs.makeTempDirectory({ prefix: "pgservice-" }); + const servicePath = path.join(tmp, "pg_service.conf"); + const files = new Map([ + [servicePath, "[prod]\nhost=db.example.com\nport=6543\ndbname=appdb\nuser=alice\n"], + ]); + yield* run(tmp, servicePath, files); + yield* fs.remove(tmp, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); - beforeEach(() => { - tmp = mkdtempSync(join(tmpdir(), "pgservice-")); - path = join(tmp, "pg_service.conf"); - writeFileSync(path, "[prod]\nhost=db.example.com\nport=6543\ndbname=appdb\nuser=alice\n"); - }); + it.effect("returns the named section's settings, remapping dbname → database", () => + fixture((_tmp, servicePath, files) => + Effect.sync(() => { + const settings = legacyServiceSettings("prod", servicePath, files); + expect(settings).toBeDefined(); + expect(Object.fromEntries(settings!)).toEqual({ + host: "db.example.com", + port: "6543", + database: "appdb", + user: "alice", + }); + }), + ), + ); - afterEach(() => { - rmSync(tmp, { recursive: true, force: true }); - }); + it.effect("returns undefined for an unknown service", () => + fixture((_tmp, servicePath, files) => + Effect.sync(() => { + expect(legacyServiceSettings("missing", servicePath, files)).toBeUndefined(); + }), + ), + ); - it("returns the named section's settings, remapping dbname → database", () => { - const settings = legacyServiceSettings("prod", path); - expect(settings).toBeDefined(); - expect(Object.fromEntries(settings!)).toEqual({ - host: "db.example.com", - port: "6543", - database: "appdb", - user: "alice", - }); - }); + it.effect("returns undefined when the service file is unreadable", () => + fixture((tmp, _servicePath, files) => { + const missingPath = `${tmp}/nope.conf`; + return Effect.sync(() => { + expect(legacyServiceSettings("prod", missingPath, files)).toBeUndefined(); + }); + }), + ); - it("returns undefined for an unknown service", () => { - expect(legacyServiceSettings("missing", path)).toBeUndefined(); - }); - - it("returns undefined when the service file is unreadable", () => { - expect(legacyServiceSettings("prod", join(tmp, "nope.conf"))).toBeUndefined(); - }); - - it("returns undefined when the file is malformed", () => { - writeFileSync(path, "host=orphan\n"); - expect(legacyServiceSettings("prod", path)).toBeUndefined(); - }); + it.effect("returns undefined when the file is malformed", () => + fixture((_tmp, servicePath, files) => { + const malformed = new Map(files).set(servicePath, "host=orphan\n"); + return Effect.sync(() => { + expect(legacyServiceSettings("prod", servicePath, malformed)).toBeUndefined(); + }); + }), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts b/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts index 611d6630d5..d1ad45e2a2 100644 --- a/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts +++ b/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts @@ -8,12 +8,12 @@ export function legacyIsDirectDbHost(host: string, projectHost: string): boolean return host.startsWith("db.") && host.endsWith(`.${projectHost}`); } -export interface LegacyPoolerFallbackOptions<A, E, R, R2, RF> { +export interface LegacyPoolerFallbackOptions<A, E, R, R2, RF, EF> { readonly run: Effect.Effect<A, E, R>; readonly retry: (pooler: LegacyPgConnInput) => Effect.Effect<A, E, R2>; readonly directHost: string; readonly eligible: boolean; - readonly resolveFallback: Effect.Effect<Option.Option<LegacyPgConnInput>, unknown, RF>; + readonly resolveFallback: Effect.Effect<Option.Option<LegacyPgConnInput>, EF, RF>; readonly classifyError?: (error: E) => boolean; readonly classifyResult?: (result: A) => boolean; } @@ -33,8 +33,8 @@ export const legacyEmitPoolerFallbackWarning = (host: string): Effect.Effect<voi ); }); -export function legacyRunWithPoolerFallback<A, E, R, R2, RF>( - options: LegacyPoolerFallbackOptions<A, E, R, R2, RF>, +export function legacyRunWithPoolerFallback<A, E, R, R2, RF, EF>( + options: LegacyPoolerFallbackOptions<A, E, R, R2, RF, EF>, ): Effect.Effect<A, E, R | R2 | RF | Output> { const resolveFallback = options.resolveFallback.pipe( Effect.orElseSucceed(() => Option.none<LegacyPgConnInput>()), diff --git a/apps/cli/src/legacy/shared/legacy-profile-load.ts b/apps/cli/src/legacy/shared/legacy-profile-load.ts index bf139c0439..8df4a702f4 100644 --- a/apps/cli/src/legacy/shared/legacy-profile-load.ts +++ b/apps/cli/src/legacy/shared/legacy-profile-load.ts @@ -133,7 +133,7 @@ export function legacyLoadProfile( const ext = goFilepathExt(token); if (!VIPER_SUPPORTED_EXTS.has(ext)) { - return yield* failRead(`Unsupported Config Type ${JSON.stringify(ext)}`); + return yield* failRead(`Unsupported Config Type "${ext}"`); } const content = yield* fs @@ -152,12 +152,17 @@ export function legacyLoadProfile( ), ); - let parsed: unknown; - try { - parsed = parseYaml(content); - } catch (cause) { - return yield* failRead(`While parsing config: ${parseDetail(cause)}`); - } + let parsed = yield* Effect.try({ + try: () => parseYaml(content), + catch: (cause) => new LegacyProfileLoadError({ message: parseDetail(cause) }), + }).pipe( + Effect.mapError( + (error) => + new LegacyProfileLoadError({ + message: `failed to read profile: While parsing config: ${error.message}`, + }), + ), + ); if (parsed === null || parsed === undefined) { parsed = {}; } @@ -175,7 +180,7 @@ export function legacyLoadProfile( // A same-key case collision is nondeterministic in Go (map iteration // order) — document order (last wins) is used here. const config: Record<string, unknown> = {}; - for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { + for (const [key, value] of Object.entries(parsed)) { config[key.toLowerCase()] = value; } diff --git a/apps/cli/src/legacy/shared/legacy-profile-load.unit.test.ts b/apps/cli/src/legacy/shared/legacy-profile-load.unit.test.ts index c475f8bef5..b556dbe8b2 100644 --- a/apps/cli/src/legacy/shared/legacy-profile-load.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-profile-load.unit.test.ts @@ -1,10 +1,8 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; -import { BunServices } from "@effect/platform-bun"; -import { afterAll, describe, expect, it } from "@effect/vitest"; -import { Effect, FileSystem } from "effect"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { legacyLoadProfile, @@ -12,8 +10,8 @@ import { type LegacyProfileLoadError, } from "./legacy-profile-load.ts"; -const tempRoot = mkdtempSync(join(tmpdir(), "supabase-profile-load-")); -afterAll(() => rmSync(tempRoot, { recursive: true, force: true })); +const tempRoot = useLegacyTempWorkdir("supabase-profile-load-"); +const testPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); const load = (token: string) => Effect.gen(function* () { @@ -27,11 +25,13 @@ const loadError = (token: string) => Effect.map((error: LegacyProfileLoadError) => error.message), ); -const writeProfile = (name: string, content: string): string => { - const filePath = join(tempRoot, name); - writeFileSync(filePath, content); - return filePath; -}; +const writeProfile = (name: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = testPath.join(tempRoot.current, name); + yield* fs.writeFileString(filePath, content); + return filePath; + }).pipe(Effect.provide(BunServices.layer)); describe("legacyLoadProfile", () => { it.effect("resolves built-in profile names case-insensitively (Go strings.EqualFold)", () => @@ -69,8 +69,8 @@ describe("legacyLoadProfile", () => { Effect.gen(function* () { // Node's `path.extname(".yml")` is "" — Go's filepath.Ext is ".yml", so // viper accepts the type and fails at the open() instead. - expect(yield* loadError(join(tempRoot, ".yml"))).toBe( - `failed to read profile: open ${join(tempRoot, ".yml")}: no such file or directory`, + expect(yield* loadError(testPath.join(tempRoot.current, ".yml"))).toBe( + `failed to read profile: open ${testPath.join(tempRoot.current, ".yml")}: no such file or directory`, ); }), ); @@ -85,15 +85,16 @@ describe("legacyLoadProfile", () => { it.effect("fails on a directory with Go's read error", () => Effect.gen(function* () { - const dir = join(tempRoot, "dir.yml"); - mkdirSync(dir, { recursive: true }); + const fs = yield* FileSystem.FileSystem; + const dir = testPath.join(tempRoot.current, "dir.yml"); + yield* fs.makeDirectory(dir, { recursive: true }); expect(yield* loadError(dir)).toBe(`failed to read profile: read ${dir}: is a directory`); - }), + }).pipe(Effect.provide(BunServices.layer)), ); it.effect("resolves a valid YAML profile to its api_url", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "valid.yml", [ "name: harness", @@ -111,7 +112,7 @@ describe("legacyLoadProfile", () => { const fs = yield* FileSystem.FileSystem; // `Name:` / `API_URL:` decode exactly like their lowercase spellings // (viper `insensitiviseMap`; review r3689635101). - const file = writeProfile( + const file = yield* writeProfile( "mixed-case.yml", [ "Name: harness", @@ -136,7 +137,7 @@ describe("legacyLoadProfile", () => { expect(builtin.dashboardUrl).toBe("https://supabase.green/dashboard"); // YAML: `project_host`/`dashboard_url` are required, `pooler_host` is // `omitempty` and stays empty when absent (disables the MITM assertion). - const file = writeProfile( + const file = yield* writeProfile( "endpoints.yml", [ "name: harness", @@ -154,7 +155,7 @@ describe("legacyLoadProfile", () => { it.effect("reports unknown keys LOWERCASED, like viper's pre-decode normalization", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "bogus-upper.yml", [ "name: harness", @@ -178,7 +179,7 @@ describe("legacyLoadProfile", () => { expect((yield* legacyLoadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); // File profile: `UnmarshalExact` populates Name from the required // `name:` key, NOT from the file path. - const file = writeProfile( + const file = yield* writeProfile( "named.yml", [ "name: harness", @@ -195,7 +196,7 @@ describe("legacyLoadProfile", () => { Effect.gen(function* () { // Byte-captured from the Go binary (`od -c`, PR #5974 round 7): keys // sorted, every line padded with spaces to the longest line's width. - const file = writeProfile( + const file = yield* writeProfile( "extra-keys.yml", [ "name: extra", @@ -220,7 +221,7 @@ describe("legacyLoadProfile", () => { Effect.gen(function* () { // Byte-captured from the Go binary: `invalid profile: ` + one line per // failing field (struct order), padded to the longest line's width. - const file = writeProfile("incomplete.yml", "name: incomplete\n"); + const file = yield* writeProfile("incomplete.yml", "name: incomplete\n"); const lines = [ "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'required' tag", "Key: 'Profile.DashboardURL' Error:Field validation for 'DashboardURL' failed on the 'required' tag", @@ -233,7 +234,7 @@ describe("legacyLoadProfile", () => { it.effect("reports a missing name (only) — required covers empty strings", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "noname.yml", [ "api_url: http://127.0.0.1:44444", @@ -253,7 +254,7 @@ describe("legacyLoadProfile", () => { Effect.gen(function* () { // Binary-verified: viper decodes with WeaklyTypedInput, so the int // reaches go-playground/validator and fails the `http_url` tag. - const file = writeProfile( + const file = yield* writeProfile( "typebad.yml", [ "name: t", @@ -270,7 +271,7 @@ describe("legacyLoadProfile", () => { it.effect("validates the hostname_rfc1123 and http_url format tags", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "badhost.yml", [ "name: t", @@ -292,7 +293,7 @@ describe("legacyLoadProfile", () => { Effect.gen(function* () { // Detail text comes from the JS yaml package (documented micro- // divergence); the class — abort before any request — matches Go. - const file = writeProfile("malformed.yml", "name: [broken\n api_url"); + const file = yield* writeProfile("malformed.yml", "name: [broken\n api_url"); const message = yield* loadError(file); expect(message).toMatch(/^failed to read profile: While parsing config: /); }), @@ -300,7 +301,7 @@ describe("legacyLoadProfile", () => { it.effect("fails closed on unconvertible values (array on a string field)", () => Effect.gen(function* () { - const file = writeProfile( + const file = yield* writeProfile( "arrayval.yml", [ "name: t", diff --git a/apps/cli/src/legacy/shared/legacy-project-create-core.ts b/apps/cli/src/legacy/shared/legacy-project-create-core.ts index bf6d824270..4b56afb8d5 100644 --- a/apps/cli/src/legacy/shared/legacy-project-create-core.ts +++ b/apps/cli/src/legacy/shared/legacy-project-create-core.ts @@ -28,6 +28,7 @@ import { legacyPromptProjectName, legacyPromptProjectRegion, } from "../commands/projects/projects.prompt.ts"; +import { legacyErrorMessage } from "./legacy-error-message.ts"; type CreateInput = typeof V1CreateAProjectInput.Type; @@ -143,7 +144,9 @@ export const legacyProjectCreateCore = Effect.fnUntraced(function* ( Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.mapError( (cause) => - new LegacyProjectsCreateNetworkError({ message: `failed to create project: ${cause}` }), + new LegacyProjectsCreateNetworkError({ + message: `failed to create project: ${legacyErrorMessage(cause)}`, + }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.ts b/apps/cli/src/legacy/shared/legacy-project-environment.ts index aea7fc645d..798725c47c 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.ts @@ -1,9 +1,23 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; - import type { ProjectEnvironment } from "@supabase/config"; +import { Data, Effect, FileSystem, Path } from "effect"; import { parseDotEnv } from "./legacy-dotenv.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +export class LegacyProjectEnvironmentError extends Data.TaggedError( + "LegacyProjectEnvironmentError", +)<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Fills the gap between `@supabase/config`'s `loadProjectEnvironment` and @@ -55,17 +69,40 @@ export function legacyCandidateDotenvFilenames(env: string): ReadonlyArray<strin * fails `Config.Load` before `stop`/`status` touch Docker, rather than silently * skipping the bad line. */ -function readDotEnvFile(path: string): Record<string, string> | undefined { - if (!existsSync(path)) return undefined; +function readDotEnvFile( + fs: FileSystem.FileSystem, + filePath: string, +): Effect.Effect<Record<string, string> | undefined, LegacyProjectEnvironmentError> { + return Effect.gen(function* () { + const exists = yield* fs.exists(filePath).pipe( + Effect.mapError( + (cause) => + new LegacyProjectEnvironmentError({ + message: `failed to read environment file: ${filePath} (${String(cause)})`, + cause, + }), + ), + ); + if (!exists) return undefined; - const contents = readFileSync(path, "utf8"); - try { - return parseDotEnv(contents); - } catch (cause) { - throw new Error( - `failed to parse environment file: ${path} (${cause instanceof Error ? cause.message : String(cause)})`, + const contents = yield* fs.readFileString(filePath).pipe( + Effect.mapError( + (cause) => + new LegacyProjectEnvironmentError({ + message: `failed to read environment file: ${filePath} (${String(cause)})`, + cause, + }), + ), ); - } + return yield* Effect.try({ + try: () => parseDotEnv(contents), + catch: (cause) => + new LegacyProjectEnvironmentError({ + message: `failed to parse environment file: ${filePath} (${cause instanceof Error ? cause.message : String(cause)})`, + cause, + }), + }); + }); } /** @@ -100,42 +137,54 @@ function readDotEnvFile(path: string): Record<string, string> | undefined { export function legacyResolveProjectEnvironmentValues( projectEnv: ProjectEnvironment | null, workdir: string, -): Record<string, string> { - const env = process.env["SUPABASE_ENV"] || "development"; - const filenames = legacyCandidateDotenvFilenames(env); - const merged: Record<string, string> = {}; + ambientEnvironment: Readonly<Record<string, string>>, +): Effect.Effect< + Record<string, string>, + LegacyProjectEnvironmentError, + FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = ambientEnvironment["SUPABASE_ENV"] || "development"; + const filenames = legacyCandidateDotenvFilenames(env); + const merged: Record<string, string> = {}; - const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); - const projectRoot = projectEnv?.paths.projectRoot ?? workdir; + const supabaseDir = projectEnv?.paths.supabaseDir ?? path.join(workdir, "supabase"); + const projectRoot = projectEnv?.paths.projectRoot ?? workdir; - // supabase/ dir first, then its parent (the project root) — matching Go's - // directory walk order. Within a directory, `godotenv.Load`'s "never - // override an already-set var" means first-processed-wins, so the plain - // merge below (skip keys already present) reproduces both orderings at once. - for (const dir of [supabaseDir, projectRoot]) { - for (const filename of filenames) { - const parsed = readDotEnvFile(join(dir, filename)); - if (parsed === undefined) continue; - for (const [key, value] of Object.entries(parsed)) { - if (!(key in merged)) merged[key] = value; + // supabase/ dir first, then its parent (the project root) — matching Go's + // directory walk order. Within a directory, `godotenv.Load`'s "never + // override an already-set var" means first-processed-wins, so the plain + // merge below (skip keys already present) reproduces both orderings at once. + for (const dir of [supabaseDir, projectRoot]) { + for (const filename of filenames) { + const parsed = yield* readDotEnvFile(fs, path.join(dir, filename)); + if (parsed === undefined) continue; + for (const [key, value] of Object.entries(parsed)) { + if (!(key in merged)) merged[key] = value; + } } } - } - const ambientOverrides: Record<string, string> = {}; - if (projectEnv !== null) { - for (const [key, value] of Object.entries(projectEnv.values)) { - if (projectEnv.sources[key] === "ambient") { - ambientOverrides[key] = value; + const ambientOverrides: Record<string, string> = {}; + if (projectEnv !== null) { + // `ambientEnvironment` is the explicit shell view supplied by the owning + // Effect boundary. It must win over project dotenv values just like the + // ambient entries in `loadProjectEnvironment`, while keeping test and + // runtime resolution free of process-global mutation. + Object.assign(ambientOverrides, ambientEnvironment); + for (const [key, value] of Object.entries(projectEnv.values)) { + if (projectEnv.sources[key] === "ambient") { + ambientOverrides[key] = value; + } } - } - } else { - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { + } else { + for (const [key, value] of Object.entries(ambientEnvironment)) { ambientOverrides[key] = value; } } - } - return { ...merged, ...ambientOverrides }; + return { ...merged, ...ambientOverrides }; + }); } diff --git a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts index dbe968d09d..6a0778f43a 100644 --- a/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-project-environment.unit.test.ts @@ -1,300 +1,667 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - +import { BunServices } from "@effect/platform-bun"; import type { ProjectEnvironment } from "@supabase/config"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { legacyResolveProjectEnvironmentValues } from "./legacy-project-environment.ts"; - -let root: string; -let supabaseDir: string; - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), "supabase-legacy-project-env-")); - supabaseDir = join(root, "supabase"); - mkdirSync(supabaseDir, { recursive: true }); -}); +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, FileSystem, Option, Path } from "effect"; + +import { + LegacyProjectEnvironmentError, + legacyResolveProjectEnvironmentValues, +} from "./legacy-project-environment.ts"; + +const EMPTY_ENV: Readonly<Record<string, string>> = {}; +type ProjectSource = "ambient" | ".env" | ".env.local"; + +interface Fixture { + readonly root: string; + readonly supabaseDir: string; + readonly join: (...parts: ReadonlyArray<string>) => string; + readonly write: ( + filePath: string, + contents: string, + ) => Effect.Effect<void, LegacyProjectEnvironmentError>; + readonly project: ( + values?: Record<string, string>, + sources?: Record<string, ProjectSource>, + ) => ProjectEnvironment; +} -afterEach(() => { - rmSync(root, { recursive: true, force: true }); - delete process.env["SUPABASE_ENV"]; - delete process.env["SUPABASE_PROJECT_ID"]; -}); +type FixtureUse<A> = ( + fixture: Fixture, +) => Effect.Effect<A, LegacyProjectEnvironmentError, FileSystem.FileSystem | Path.Path>; + +const withFixture = <A>(use: FixtureUse<A>): Effect.Effect<A, LegacyProjectEnvironmentError> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectory({ prefix: "supabase-legacy-project-env-" }).pipe( + Effect.mapError( + (cause) => + new LegacyProjectEnvironmentError({ + message: "failed to create project environment fixture", + cause, + }), + ), + ); + const supabaseDir = path.join(root, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new LegacyProjectEnvironmentError({ + message: "failed to create project environment fixture directory", + cause, + }), + ), + ); -function fakeProjectEnv( - values: Record<string, string> = {}, - sources: Record<string, "ambient" | ".env" | ".env.local"> = {}, -): ProjectEnvironment { - return { - paths: { - projectRoot: root, + const fixture: Fixture = { + root, supabaseDir, - configPath: join(supabaseDir, "config.toml"), - envPath: join(supabaseDir, ".env"), - envLocalPath: join(supabaseDir, ".env.local"), - }, - values, - loadedPaths: [], - // Default every given value to "ambient" unless the caller says otherwise — - // matches how most tests use this helper (representing an already-resolved, - // highest-precedence value) without forcing every call site to spell it out. - sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), - }; -} + join: (...parts) => path.join(...parts), + write: (filePath, contents) => + fs.writeFileString(filePath, contents).pipe( + Effect.mapError( + (cause) => + new LegacyProjectEnvironmentError({ + message: `failed to write project environment fixture: ${filePath}`, + cause, + }), + ), + ), + project: (values = {}, sources = {}) => { + const resolvedSources: Record<string, ProjectSource> = {}; + for (const key of Object.keys(values)) { + resolvedSources[key] = sources[key] ?? "ambient"; + } + return { + paths: { + projectRoot: root, + supabaseDir, + configPath: path.join(supabaseDir, "config.toml"), + envPath: path.join(supabaseDir, ".env"), + envLocalPath: path.join(supabaseDir, ".env.local"), + }, + values, + loadedPaths: [], + sources: resolvedSources, + }; + }, + }; + + return yield* Effect.acquireUseRelease( + Effect.succeed(root), + () => use(fixture), + (directory) => fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ); + }).pipe(Effect.provide(BunServices.layer)); describe("legacyResolveProjectEnvironmentValues", () => { - it("returns just the already-loaded values when no extra dotenv files exist", () => { - const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "from-loader" }); - expect(legacyResolveProjectEnvironmentValues(projectEnv, root)).toEqual({ - SUPABASE_PROJECT_ID: "from-loader", - }); - }); - - it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); - }); - - it("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => { - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }); - - it("lets already-resolved projectEnv.values win over anything discovered locally", () => { - // `projectEnv.values` already reflects loadProjectEnvironment's correct - // ambient-wins-over-supabase/.env(.local) result; a redundant root .env - // entry for the same key must never override it. - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); - const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); - const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }); - - it("defaults SUPABASE_ENV to development when unset", () => { - writeFileSync(join(root, ".env.development"), "SUPABASE_PROJECT_ID=dev-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); - }); - - it("selects the SUPABASE_ENV-named file over the bare .env file", () => { - process.env["SUPABASE_ENV"] = "production"; - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=bare-env-project\n"); - writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); - }); - - it("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => { - process.env["SUPABASE_ENV"] = "production"; - writeFileSync(join(root, ".env.production"), "SUPABASE_PROJECT_ID=prod-project\n"); - writeFileSync(join(root, ".env.production.local"), "SUPABASE_PROJECT_ID=prod-local-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); - }); - - it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { - process.env["SUPABASE_ENV"] = "test"; - writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); - writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); - }); - - it("strips quotes the same way the shared dotenv parser does", () => { - writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); - }); - - it("ignores blank lines and comments", () => { - writeFileSync(root + "/.env", "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); - }); - - it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { - // godotenv only starts an inline comment at a `#` preceded by whitespace; - // `foo#bar` keeps the `#` verbatim. - writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); - }); + it.effect("returns just the already-loaded values when no extra dotenv files exist", () => + withFixture((fixture) => + Effect.gen(function* () { + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project({ SUPABASE_PROJECT_ID: "from-loader" }), + fixture.root, + EMPTY_ENV, + ); + expect(merged).toEqual({ SUPABASE_PROJECT_ID: "from-loader" }); + }), + ), + ); + + it.effect("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=root-env-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); + }), + ), + ); + + it.effect("prefers a supabase/-dir dotenv file over the same key in a project-root file", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=supabase-dir-project\n", + ); + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=root-dir-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }), + ), + ); + + it.effect("lets already-resolved projectEnv.values win over anything discovered locally", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=root-env-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project({ SUPABASE_PROJECT_ID: "ambient-project" }), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }), + ), + ); + + it.effect("defaults SUPABASE_ENV to development when unset", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env.development"), + "SUPABASE_PROJECT_ID=dev-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }), + ), + ); + + it.effect("defaults an explicitly empty SUPABASE_ENV to development", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env.development"), + "SUPABASE_PROJECT_ID=dev-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + { SUPABASE_ENV: "" }, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("dev-project"); + }), + ), + ); + + it.effect("selects the SUPABASE_ENV-named file over the bare .env file", () => + withFixture((fixture) => + Effect.gen(function* () { + const env = { SUPABASE_ENV: "production" }; + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=bare-env-project\n", + ); + yield* fixture.write( + fixture.join(fixture.root, ".env.production"), + "SUPABASE_PROJECT_ID=prod-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + env, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-project"); + }), + ), + ); + + it.effect("prefers the .local variant of the SUPABASE_ENV file over the non-local one", () => + withFixture((fixture) => + Effect.gen(function* () { + const env = { SUPABASE_ENV: "production" }; + yield* fixture.write( + fixture.join(fixture.root, ".env.production"), + "SUPABASE_PROJECT_ID=prod-project\n", + ); + yield* fixture.write( + fixture.join(fixture.root, ".env.production.local"), + "SUPABASE_PROJECT_ID=prod-local-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + env, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); + }), + ), + ); + + it.effect("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => + withFixture((fixture) => + Effect.gen(function* () { + const env = { SUPABASE_ENV: "test" }; + yield* fixture.write( + fixture.join(fixture.root, ".env.local"), + "SUPABASE_PROJECT_ID=local-project\n", + ); + yield* fixture.write( + fixture.join(fixture.root, ".env.test"), + "SUPABASE_PROJECT_ID=test-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + env, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("test-project"); + }), + ), + ); + + it.effect("strips quotes the same way the shared dotenv parser does", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + 'SUPABASE_AUTH_JWT_SECRET="a quoted value"\n', + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("a quoted value"); + }), + ), + ); + + it.effect("ignores blank lines and comments", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "\n# a comment\nSUPABASE_PROJECT_ID=commented-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); + }), + ), + ); + + it.effect( + "preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", + () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_AUTH_JWT_SECRET=long#secret\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); + }), + ), + ); + + it.effect("still truncates an unquoted value at a whitespace-preceded inline comment", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=54323 # local\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); + }), + ), + ); + + it.effect("strips a trailing comment after a quoted value", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + 'SUPABASE_PROJECT_ID="demo" # local\n', + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }), + ), + ); + + it.effect("accepts a colon-separated assignment", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID: colon-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); + }), + ), + ); + + it.effect( + "prefers an env-specific file over a same-key value sourced from a bare .env file", + () => + withFixture((fixture) => + Effect.gen(function* () { + const env = { SUPABASE_ENV: "development" }; + yield* fixture.write( + fixture.join(fixture.supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project( + { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, + { SUPABASE_PROJECT_ID: ".env" }, + ), + fixture.root, + env, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); + }), + ), + ); + + it.effect("still lets a truly ambient-sourced value win over any file", () => + withFixture((fixture) => + Effect.gen(function* () { + const env = { SUPABASE_ENV: "development" }; + yield* fixture.write( + fixture.join(fixture.supabaseDir, ".env.development.local"), + "SUPABASE_PROJECT_ID=env-specific-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project( + { SUPABASE_PROJECT_ID: "ambient-project" }, + { SUPABASE_PROJECT_ID: "ambient" }, + ), + fixture.root, + env, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); + }), + ), + ); + + it.effect("fails on a malformed line", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write(fixture.join(fixture.root, ".env"), "not a valid line\n"); + const exit = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Exit.findErrorOption(exit); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value.message).toMatch(/failed to parse environment file/); + } + } + }), + ), + ); + + it.effect("expands an unquoted $VAR reference to an earlier value in the same file", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); + }), + ), + ); + + it.effect("expands a braced ${VAR} reference in a double-quoted value", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n', + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); + }), + ), + ); + + it.effect("does not expand variable references inside single-quoted values", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); + }), + ), + ); + + it.effect("expands an unresolved bare reference to an empty string", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write(fixture.join(fixture.root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); + }), + ), + ); + + it.effect("expands an unresolved braced reference to an empty string", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n', + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); + }), + ), + ); + + it.effect("preserves a backslash-escaped $VAR reference as a literal", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); + }), + ), + ); + + it.effect("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n', + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); + }), + ), + ); + + it.effect("treats a bare trailing $ with no variable name as a literal", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write(fixture.join(fixture.root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); + }), + ), + ); + + it.effect("preserves a multiline quoted value alongside an unrelated SUPABASE_* key", () => + withFixture((fixture) => + Effect.gen(function* () { + const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; + yield* fixture.write( + fixture.join(fixture.root, ".env"), + `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + fixture.project(), + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); + }), + ), + ); - it("still truncates an unquoted value at a whitespace-preceded inline comment", () => { - writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID=54323 # local\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); - }); - - it("strips a trailing comment after a quoted value, matching godotenv", () => { - // godotenv's `extractVarValue` locates the quoted span by scanning forward for the - // closing quote and discards anything after - // it as a comment — the value is `demo`, not the literal `"demo"` a check that - // requires the whole trimmed remainder to end with a quote would produce. - writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }); - - it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { - // godotenv's `locateKeyName` treats `=` and `:` as interchangeable separators, - // and the repo's other dotenv parser - // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. - writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); - }); - - it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { - // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so - // a key it resolved from a plain supabase/.env file is NOT necessarily - // higher Go precedence than a same-named key from `.env.<env>.local` — - // only an "ambient" source outranks the file precedence computed locally. - process.env["SUPABASE_ENV"] = "development"; - writeFileSync( - join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - { SUPABASE_PROJECT_ID: "bare-dotenv-project" }, - { SUPABASE_PROJECT_ID: ".env" }, + describe("when no project was found (projectEnv is null)", () => { + it.effect("still reads a supabase/-dir dotenv file directly under workdir", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=fallback-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + null, + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); + }), + ), ); - const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("env-specific-project"); - }); - it("still lets a truly ambient-sourced value win over any file", () => { - process.env["SUPABASE_ENV"] = "development"; - writeFileSync( - join(supabaseDir, ".env.development.local"), - "SUPABASE_PROJECT_ID=env-specific-project\n", - ); - const projectEnv = fakeProjectEnv( - { SUPABASE_PROJECT_ID: "ambient-project" }, - { SUPABASE_PROJECT_ID: "ambient" }, + it.effect("still reads a project-root dotenv file directly under workdir", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=root-fallback-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + null, + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); + }), + ), ); - const merged = legacyResolveProjectEnvironmentValues(projectEnv, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); - }); - it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { - writeFileSync(join(root, ".env"), "not a valid line\n"); - expect(() => legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root)).toThrow( - /failed to parse environment file/, + it.effect("prefers the supabase/-dir file over the project-root file", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=supabase-dir-project\n", + ); + yield* fixture.write( + fixture.join(fixture.root, ".env"), + "SUPABASE_PROJECT_ID=root-dir-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues( + null, + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); + }), + ), ); - }); - - it("expands an unquoted $VAR reference to an earlier value in the same file", () => { - // godotenv expands unquoted/double-quoted references while loading, - // so a later key can reuse an earlier one. - writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); - }); - - it("expands a braced ${VAR} reference in a double-quoted value", () => { - writeFileSync(join(root, ".env"), 'SECRET=shh\nSUPABASE_AUTH_JWT_SECRET="${SECRET}"\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("shh"); - }); - it("does not expand variable references inside single-quoted values", () => { - // godotenv never calls expandVariables for single-quoted values — - // they stay byte-literal. - writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); - }); - - it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); - }); - - it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { - writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); - }); - - it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { - // godotenv's expandVarRegex captures a leading backslash and strips ONLY - // that backslash, returning the rest of the match verbatim instead of - // doing a lookup — even when - // BASE is defined, `demo\$BASE` must stay `demo$BASE`, not become - // `demodemo`. - writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); - }); - - it("preserves a backslash-escaped ${VAR} reference in a double-quoted value", () => { - writeFileSync(join(root, ".env"), 'BASE=demo\nSUPABASE_PROJECT_ID="demo\\${BASE}"\n'); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo${BASE}"); - }); - - it("treats a bare trailing $ with no variable name as a literal", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=demo$\n"); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); - }); - - it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { - // godotenv's parser scans the whole buffer with a cursor, not line-by-line, - // so a quoted value spanning physical - // lines — e.g. a pasted PEM private key — doesn't break parsing of the rest - // of the file. A naive line-by-line reader would see the continuation line - // as malformed and abort before SUPABASE_PROJECT_ID is ever read. - const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; - writeFileSync( - join(root, ".env"), - `PRIVATE_KEY="${pem}"\nSUPABASE_PROJECT_ID=multiline-safe-project\n`, + it.effect("lets an ambient shell var win over a dotenv value", () => + withFixture((fixture) => + Effect.gen(function* () { + yield* fixture.write( + fixture.join(fixture.supabaseDir, ".env"), + "SUPABASE_PROJECT_ID=dotenv-fallback-project\n", + ); + const merged = yield* legacyResolveProjectEnvironmentValues(null, fixture.root, { + SUPABASE_PROJECT_ID: "ambient-fallback-project", + }); + expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); + }), + ), ); - const merged = legacyResolveProjectEnvironmentValues(fakeProjectEnv(), root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("multiline-safe-project"); - }); - describe("when no project was found (projectEnv is null)", () => { - // `loadNestedEnv` runs unconditionally before `config.toml` is ever - // opened, so a missing config file must - // not skip dotenv loading — these cover the local fallback that derives - // `<workdir>/supabase`/`workdir` directly instead of giving up. - - it("still reads a supabase/-dir dotenv file directly under workdir", () => { - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); - const merged = legacyResolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("fallback-project"); - }); - - it("still reads a project-root dotenv file directly under workdir", () => { - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-fallback-project\n"); - const merged = legacyResolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-fallback-project"); - }); - - it("prefers the supabase/-dir file over the project-root file, same as the non-null case", () => { - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=supabase-dir-project\n"); - writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-dir-project\n"); - const merged = legacyResolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("supabase-dir-project"); - }); - - it("lets an ambient shell var win over a dotenv value, using process.env directly", () => { - process.env["SUPABASE_PROJECT_ID"] = "ambient-fallback-project"; - writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=dotenv-fallback-project\n"); - const merged = legacyResolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-fallback-project"); - }); - - it("returns an empty object when workdir has no dotenv files and no ambient value", () => { - const merged = legacyResolveProjectEnvironmentValues(null, root); - expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); - }); + it.effect("returns an empty object when workdir has no dotenv files and no ambient value", () => + withFixture((fixture) => + Effect.gen(function* () { + const merged = yield* legacyResolveProjectEnvironmentValues( + null, + fixture.root, + EMPTY_ENV, + ); + expect(merged["SUPABASE_PROJECT_ID"]).toBeUndefined(); + }), + ), + ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-resolve-token.ts b/apps/cli/src/legacy/shared/legacy-resolve-token.ts index 0acd3f06fd..838c50122f 100644 --- a/apps/cli/src/legacy/shared/legacy-resolve-token.ts +++ b/apps/cli/src/legacy/shared/legacy-resolve-token.ts @@ -30,6 +30,6 @@ export const resolveLegacyAccessToken: Effect.Effect< } const credentials = yield* LegacyCredentials; return yield* credentials.getAccessToken.pipe( - Effect.catch(() => Effect.succeed(Option.none<Redacted.Redacted<string>>())), + Effect.orElseSucceed(() => Option.none<Redacted.Redacted<string>>()), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index 27a3ec9128..66e7e7c546 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -5,7 +5,6 @@ import { ProjectConfigSchema, } from "@supabase/config"; import { Effect, FileSystem, Path, Schema } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; import type { PlatformError } from "effect/PlatformError"; import { Output } from "../../shared/output/output.service.ts"; @@ -14,10 +13,8 @@ import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyBold, legacyYellow } from "./legacy-colors.ts"; import { legacyLoadProjectEnv } from "./legacy-db-config.toml-read.ts"; import { legacyPromptYesNo } from "../../shared/legacy/legacy-prompt-yes-no.ts"; -import { - legacyResolveStorageCredentials, - legacyStorageGatewayFetch, -} from "./legacy-storage-credentials.ts"; +import { legacyResolveStorageCredentials } from "./legacy-storage-credentials.ts"; +import { LegacyLocalGatewayHttpClient } from "./legacy-local-gateway-http-client.ts"; import { legacyParseFileSizeLimit, legacyResolveBucketProps, @@ -251,11 +248,12 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // Build the Storage service-gateway client (local or remote). const credentials = yield* legacyResolveStorageCredentials({ projectRef, config }); + const localGatewayHttpClient = yield* LegacyLocalGatewayHttpClient; - // All gateway operations run with an explicit non-DoH fetch (CA-trusting for - // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup inside - // `legacyResolveStorageCredentials` runs BEFORE this scope, so it still honors - // `--dns-resolver https`, matching `tenant.GetApiKeys`. + // All gateway operations run through the explicit local-gateway transport + // boundary. The api-keys lookup inside `legacyResolveStorageCredentials` runs + // BEFORE this scope, so it still honors `--dns-resolver https`, matching + // `tenant.GetApiKeys`. const gatewayOps = Effect.gen(function* () { const gateway = yield* legacyMakeStorageGateway({ baseUrl: credentials.baseUrl, @@ -302,12 +300,7 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { } }); - yield* gatewayOps.pipe( - Effect.provideService( - FetchHttpClient.Fetch, - legacyStorageGatewayFetch(credentials.localKongCa), - ), - ); + yield* localGatewayHttpClient.use(credentials.localKongCa, gatewayOps); }); type BucketsConfig = Readonly< @@ -359,7 +352,7 @@ const upsertBuckets = Effect.fnUntraced(function* ( propsByName: ReadonlyMap<string, LegacyUpsertBucketProps>, summary: SeedSummary, ) { - const existing = yield* gateway.listBuckets(); + const existing = yield* gateway.listBuckets; const byName = new Map(existing.map((b) => [b.name, b.id])); for (const [name, props] of propsByName) { @@ -396,7 +389,7 @@ const upsertVectorBuckets = Effect.fnUntraced(function* ( configuredNames: ReadonlyArray<string>, summary: SeedSummary, ) { - const existing = yield* gateway.listVectorBuckets(); + const existing = yield* gateway.listVectorBuckets; const existingSet = new Set(existing); const configuredSet = new Set(configuredNames); const toDelete = existing.filter((name) => !configuredSet.has(name)); @@ -437,7 +430,7 @@ const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( configuredNames: ReadonlyArray<string>, summary: SeedSummary, ) { - const existing = yield* gateway.listAnalyticsBuckets(); + const existing = yield* gateway.listAnalyticsBuckets; const existingSet = new Set(existing); const configuredSet = new Set(configuredNames); const toDelete = existing.filter((name) => !configuredSet.has(name)); @@ -495,7 +488,7 @@ const handleVectorError = Effect.fnUntraced(function* ( summary.vector_skipped = true; return; } - return yield* Effect.fail(error); + return yield* error; }); // Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). @@ -508,6 +501,7 @@ const uploadObjects = Effect.fnUntraced(function* ( bucketsConfig: BucketsConfig, summary: SeedSummary, ) { + const posixPath = yield* Path.Path.pipe(Effect.provide(Path.layer)); for (const [name, bucket] of Object.entries(bucketsConfig)) { const objectsPath = bucket.objects_path; if (objectsPath.length === 0) { @@ -528,7 +522,13 @@ const uploadObjects = Effect.fnUntraced(function* ( files, (file) => Effect.gen(function* () { - const dstPath = legacyBucketObjectKey(name, displayRoot, file.displayPath); + const dstPath = legacyBucketObjectKey( + path, + posixPath, + name, + displayRoot, + file.displayPath, + ); yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); // Content-type is byte-driven: Go sniffs the first 512 bytes with // http.DetectContentType, refining only a generic text/plain by @@ -604,19 +604,20 @@ const collectDir = ( // `stat` follows symlinks and has no `lstat`). const isSymlink = yield* fs.readLink(absChild).pipe( Effect.as(true), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); if (isSymlink) { // `isUploadableEntry` OPENS the target then stats the // handle; it uploads only a regular file. `stat` alone would queue an // unreadable target and abort later at upload, so mirror that: open + stat. + const unknownFileType = "Unknown"; const targetType = yield* Effect.scoped( Effect.gen(function* () { const handle = yield* fs.open(absChild, { flag: "r" }); const targetInfo = yield* handle.stat; return targetInfo.type; }), - ).pipe(Effect.catch(() => Effect.succeed("Unknown" as const))); + ).pipe(Effect.orElseSucceed(() => unknownFileType)); if (targetType === "File") { collected.push({ absPath: absChild, displayPath: displayChild }); } else { diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.ts index e7f6706f5f..6efdae87ac 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { Effect, type FileSystem, type Path } from "effect"; +import { Cause, Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; @@ -106,6 +106,7 @@ export const legacySeedData = <E>( workdir: string, path: Path.Path, seeds: ReadonlyArray<LegacySeedFile>, + projectEnv: Readonly<Record<string, string>>, mapError: (message: string) => E, ): Effect.Effect<void, E, Output> => Effect.gen(function* () { @@ -135,7 +136,11 @@ export const legacySeedData = <E>( const content = yield* fs.readFileString( path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), ); - yield* checkScannerBufferSize(content, (message) => new Error(message)); + yield* checkScannerBufferSize( + content, + (message) => new Cause.UnknownError(undefined, String(message)), + projectEnv, + ); const lines = legacySplitAndTrim(content); const statements = seed.dirty ? [] : lines; yield* session.exec("BEGIN"); diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts index 32f0f3aa0e..b938d24b46 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts @@ -1,9 +1,6 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Data, Effect, Exit, FileSystem, Path } from "effect"; +import { Data, Effect, Exit, FileSystem, Layer, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; @@ -34,6 +31,25 @@ function fakeSeedSession(opts: { restoreRoleSql?: string } = {}) { return { session, calls }; } +const withTempDirectory = <A>( + prefix: string, + use: ( + directory: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect<A, Error, FileSystem.FileSystem | Path.Path>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectory({ prefix }); + return yield* Effect.acquireUseRelease( + Effect.succeed(directory), + (root) => use(root, fs, path), + (root) => fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore), + ); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie); + // Glob matching itself is `legacyPathMatch` (`../../../shared/legacy-path-match.ts`), // a faithful port of Go's `path.Match` already covered by // `legacy-path-match.unit.test.ts` (including the `^`-only negation / `!`-is-literal @@ -44,22 +60,19 @@ describe("legacyGetPendingSeeds (glob character classes)", () => { it.effect( "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - writeFileSync(join(dir, "b.sql"), "select 2;"); const { session } = fakeSeedSession(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // `[!a]` is a positive class of the literal members `!` and `a` — only a - // leading `^` negates. So this pattern matches `a.sql`, not `b.sql` (the old - // shell-style bug negated on `!` too, and would have matched `b.sql` instead). - const pending = yield* legacyGetPendingSeeds(session, fs, path, ["[!a].sql"], dir); - expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); - rmSync(dir, { recursive: true, force: true }); - }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), + return withTempDirectory("legacy-seed-glob-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString(path.join(dir, "a.sql"), "select 1;"); + yield* fs.writeFileString(path.join(dir, "b.sql"), "select 2;"); + // `[!a]` is a positive class of the literal members `!` and `a` — only a + // leading `^` negates. So this pattern matches `a.sql`, not `b.sql` (the old + // shell-style bug negated on `!` too, and would have matched `b.sql` instead). + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["[!a].sql"], dir); + expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); + }).pipe( + Effect.provide(Layer.mergeAll(mockOutput({ format: "text" }).layer, BunServices.layer)), + ), ); }, ); @@ -70,59 +83,56 @@ describe("legacyGetPendingSeeds (glob character classes)", () => { // An unclosed `[` is malformed per Go's `path.Match` grammar (`ErrBadPattern`), which // `fs.Glob` reports as `failed to glob files: syntax error in pattern` — not the // generic `no files matched pattern` a same-shaped but well-formed glob would get. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-glob-")); const { session } = fakeSeedSession(); const out = mockOutput({ format: "text" }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const pending = yield* legacyGetPendingSeeds(session, fs, path, ["seed[.sql"], dir); - expect(pending).toEqual([]); - expect(out.rawChunks.map((c) => c.text).join("")).toContain( - "failed to glob files: syntax error in pattern", - ); - rmSync(dir, { recursive: true, force: true }); - }).pipe(Effect.provide(out.layer), Effect.provide(BunServices.layer)); + return withTempDirectory("legacy-seed-glob-", (dir, fs, path) => + Effect.gen(function* () { + const pending = yield* legacyGetPendingSeeds(session, fs, path, ["seed[.sql"], dir); + expect(pending).toEqual([]); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "failed to glob files: syntax error in pattern", + ); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, BunServices.layer))), + ); }, ); }); const runSeed = ( session: LegacyDbSession, + fs: FileSystem.FileSystem, workdir: string, + path: Path.Path, seeds: ReadonlyArray<{ readonly path: string; readonly hash: string; readonly dirty: boolean }>, ) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return yield* legacySeedData( - session, - fs, - workdir, - path, - seeds, - (message) => new TestError({ message }), - ); - }).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(BunServices.layer)); + legacySeedData( + session, + fs, + workdir, + path, + seeds, + {}, + (message) => new TestError({ message }), + ).pipe(Effect.provide(mockOutput({ format: "text" }).layer)); describe("legacySeedData (dirty parse)", () => { it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { // `ExecBatchWithCache` reads + parses the file UNCONDITIONALLY before the // dirty check, so a dirty seed pointing at a missing file must fail (and leave // the previous hash) rather than silently upserting the new hash. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); const { session, calls } = fakeSeedSession(); - return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - // The hash upsert is a `query`; the only execs that ran are the - // schema/table creation (whose DDL also mentions `seed_files`), so assert - // no `query` ran rather than substring-matching the table name. - expect(calls.some((c) => c.kind === "query")).toBe(false); - rmSync(dir, { recursive: true, force: true }); - }), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + runSeed(session, fs, dir, path, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + // The hash upsert is a `query`; the only execs that ran are the + // schema/table creation (whose DDL also mentions `seed_files`), so assert + // no `query` ran rather than substring-matching the table name. + expect(calls.some((c) => c.kind === "query")).toBe(false); + }), + ), ), ); }); @@ -133,86 +143,83 @@ describe("legacySeedData (dirty parse)", () => { // Go's SeedFile.ExecBatchWithCache parses through the same parseFile every // other file type does, so an oversized statement must abort the seed run — // same as legacy-migration-apply.unit.test.ts's equivalent case for migrations. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-scanner-")); // Raw text must exceed the 4096-byte floor Go's bufio.Scanner starts at // regardless of the configured limit (see legacy-migration-apply.unit.test.ts's // equivalent case for the exact same 4096-byte floor). - writeFileSync(join(dir, "big.sql"), `select '${"x".repeat(5000)}';`); const { session, calls } = fakeSeedSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; - return runSeed(session, dir, [{ path: "big.sql", hash: "newhash", dirty: false }]).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(calls.some((c) => c.sql.includes("select"))).toBe(false); - rmSync(dir, { recursive: true, force: true }); - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - }), - ), + return withTempDirectory("legacy-seed-scanner-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString(path.join(dir, "big.sql"), `select '${"x".repeat(5000)}';`); + const exit = yield* legacySeedData( + session, + fs, + dir, + path, + [{ path: "big.sql", hash: "newhash", dirty: false }], + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, + (message) => new TestError({ message }), + ).pipe(Effect.provide(mockOutput({ format: "text" }).layer), Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(calls.some((c) => c.sql.includes("select"))).toBe(false); + }), ); }, ); it.effect("refreshes the hash for a dirty seed that parses, without running statements", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); - writeFileSync(join(dir, "data.sql"), "insert into t values (1);"); const { session, calls } = fakeSeedSession(); - return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( - Effect.tap(() => - Effect.sync(() => { - // Go's CreateSeedTable scopes the lock timeout to the DDL transaction - // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. - expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); - // Statements are NOT executed for a dirty seed, but the hash IS upserted. - expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); - expect(calls.some((c) => c.kind === "query" && c.sql.includes("seed_files"))).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString(path.join(dir, "data.sql"), "insert into t values (1);"); + yield* runSeed(session, fs, dir, path, [ + { path: "data.sql", hash: "newhash", dirty: true }, + ]); + // Go's CreateSeedTable scopes the lock timeout to the DDL transaction + // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. + expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); + // Statements are NOT executed for a dirty seed, but the hash IS upserted. + expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); + expect(calls.some((c) => c.kind === "query" && c.sql.includes("seed_files"))).toBe(true); + }), ); }); it.effect("re-asserts the stepped-down role before the seed_files upsert", () => { // A seed's own `reset role` reverts a stepped-down session to the login role, // which used to fail the CLI's hash upsert with 42501 (supabase/cli#6236). - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); - writeFileSync(join(dir, "data.sql"), "set role r;\ninsert into t values (1);\nreset role;"); const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe( - Effect.tap(() => - Effect.sync(() => { - const sqls = calls.map((c) => c.sql); - const restoreAt = sqls.indexOf("SET SESSION ROLE postgres"); - const upsertAt = calls.findIndex( - (c) => c.kind === "query" && c.sql.includes("seed_files"), - ); - expect(restoreAt).toBeGreaterThan(sqls.indexOf("reset role")); - expect(upsertAt).toBeGreaterThan(restoreAt); - expect(sqls.lastIndexOf("COMMIT")).toBeGreaterThan(upsertAt); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString( + path.join(dir, "data.sql"), + "set role r;\ninsert into t values (1);\nreset role;", + ); + yield* runSeed(session, fs, dir, path, [{ path: "data.sql", hash: "h", dirty: false }]); + const sqls = calls.map((c) => c.sql); + const restoreAt = sqls.indexOf("SET SESSION ROLE postgres"); + const upsertAt = calls.findIndex((c) => c.kind === "query" && c.sql.includes("seed_files")); + expect(restoreAt).toBeGreaterThan(sqls.indexOf("reset role")); + expect(upsertAt).toBeGreaterThan(restoreAt); + expect(sqls.lastIndexOf("COMMIT")).toBeGreaterThan(upsertAt); + }), ); }); it.effect("restores the role right after a mid-seed reset, before later statements", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); - writeFileSync(join(dir, "data.sql"), "set role r;\nreset role;\ninsert into t values (1);"); const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); - return runSeed(session, dir, [{ path: "data.sql", hash: "h", dirty: false }]).pipe( - Effect.tap(() => - Effect.sync(() => { - const sqls = calls.map((c) => c.sql); - const resetAt = sqls.indexOf("reset role"); - // Injected immediately, so the following insert runs as postgres again. - expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); - expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString( + path.join(dir, "data.sql"), + "set role r;\nreset role;\ninsert into t values (1);", + ); + yield* runSeed(session, fs, dir, path, [{ path: "data.sql", hash: "h", dirty: false }]); + const sqls = calls.map((c) => c.sql); + const resetAt = sqls.indexOf("reset role"); + // Injected immediately, so the following insert runs as postgres again. + expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); + expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1); + }), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index 9fbc4d7cfb..53d475f160 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -79,6 +79,7 @@ export const legacyApplySeedFiles = ( path: Path.Path, workdir: string, config: LegacySeedConfig, + projectEnv: Readonly<Record<string, string>>, ) => Effect.gen(function* () { const output = yield* Output; @@ -157,6 +158,7 @@ export const legacyApplySeedFiles = ( yield* checkScannerBufferSize( content, (message) => new LegacyMigrationSeedError({ message }), + projectEnv, ); statements = legacySplitAndTrim(content); } diff --git a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts index 303c714338..0351a74511 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts @@ -1,6 +1,3 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, Path } from "effect"; @@ -26,56 +23,74 @@ function fakeSession() { return { session, queries }; } -const run = ( - session: LegacyDbSession, - workdir: string, - sqlPaths: ReadonlyArray<string>, - out: ReturnType<typeof mockOutput>, +const withTempDirectory = <A>( + prefix: string, + use: ( + directory: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect<A, Error, FileSystem.FileSystem | Path.Path>, ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* legacyApplySeedFiles(session, fs, path, workdir, { enabled: true, sqlPaths }); - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))); + const directory = yield* fs.makeTempDirectory({ prefix }); + return yield* Effect.acquireUseRelease( + Effect.succeed(directory), + (root) => use(root, fs, path), + (root) => fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore), + ); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie); describe("legacyApplySeedFiles seed glob", () => { it.effect("treats a backslash escape as a glob metacharacter (matches the real file)", () => { // `io/fs.hasMeta` counts `\` (escape), so `seed\.sql` globs via path.Match // and matches the literal `seed.sql` — not a file named `seed\.sql`. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); - writeFileSync(join(dir, "seed.sql"), "insert into t values (1);"); const { session, queries } = fakeSession(); const out = mockOutput(); - return run(session, dir, ["seed\\.sql"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - // The seed file was found and recorded under its clean path. - const upsert = queries.find((q) => - q.sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(upsert?.params?.[0]).toBe("seed.sql"); - expect(out.rawChunks.map((c) => c.text)).toContain("Seeding data from seed.sql...\n"); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString(path.join(dir, "seed.sql"), "insert into t values (1);"); + yield* legacyApplySeedFiles( + session, + fs, + path, + dir, + { enabled: true, sqlPaths: ["seed\\.sql"] }, + {}, + ); + const upsert = queries.find((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upsert?.params?.[0]).toBe("seed.sql"); + expect(out.rawChunks.map((c) => c.text)).toContain("Seeding data from seed.sql...\n"); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))), ); }); it.effect("warns (no match) when a backslash-escaped pattern's literal file is absent", () => { // `missing\.sql` escapes to the literal `missing.sql`; with no such file it matches // nothing and Go emits a single `no files matched pattern` warning. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); const { session, queries } = fakeSession(); const out = mockOutput(); - return run(session, dir, ["missing\\.sql"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - expect(queries.some((q) => q.sql.includes("seed_files"))).toBe(false); - expect(out.rawChunks.map((c) => c.text).join("")).toContain( - "no files matched pattern: missing\\.sql", - ); - rmSync(dir, { recursive: true, force: true }); - }), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + legacyApplySeedFiles( + session, + fs, + path, + dir, + { enabled: true, sqlPaths: ["missing\\.sql"] }, + {}, + ).pipe( + Effect.tap(() => + Effect.sync(() => { + expect(queries.some((q) => q.sql.includes("seed_files"))).toBe(false); + expect(out.rawChunks.map((c) => c.text).join("")).toContain( + "no files matched pattern: missing\\.sql", + ); + }), + ), + Effect.provide(Layer.mergeAll(BunServices.layer, out.layer)), ), ); }); @@ -87,27 +102,32 @@ describe("legacyApplySeedFiles seed glob", () => { // `db.migrations.schema_paths` resolves through — which expands a directory match to its // recursively-walked, sorted `.sql` files rather than treating the directory itself as a // seed file. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); - mkdirSync(join(dir, "seeds")); - writeFileSync(join(dir, "seeds", "b.sql"), "insert into t values (2);"); - writeFileSync(join(dir, "seeds", "a.sql"), "insert into t values (1);"); - writeFileSync(join(dir, "seeds", "README.md"), "not a seed file"); const { session, queries } = fakeSession(); const out = mockOutput(); - return run(session, dir, ["seeds"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - const upserts = queries.filter((q) => - q.sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); - expect(out.rawChunks.map((c) => c.text)).toEqual([ - "Seeding data from seeds/a.sql...\n", - "Seeding data from seeds/b.sql...\n", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + Effect.gen(function* () { + const seeds = path.join(dir, "seeds"); + yield* fs.makeDirectory(seeds); + yield* fs.writeFileString(path.join(seeds, "b.sql"), "insert into t values (2);"); + yield* fs.writeFileString(path.join(seeds, "a.sql"), "insert into t values (1);"); + yield* fs.writeFileString(path.join(seeds, "README.md"), "not a seed file"); + yield* legacyApplySeedFiles( + session, + fs, + path, + dir, + { enabled: true, sqlPaths: ["seeds"] }, + {}, + ); + const upserts = queries.filter((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); + expect(out.rawChunks.map((c) => c.text)).toEqual([ + "Seeding data from seeds/a.sql...\n", + "Seeding data from seeds/b.sql...\n", + ]); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))), ); }, ); @@ -120,26 +140,28 @@ describe("legacyApplySeedFiles scanner buffer size", () => { // Ports the same `parseFile` every migration/globals/schema-file caller goes // through (see `checkScannerBufferSize`'s doc comment), so an oversized // statement must abort here too, not execute silently. - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-scanner-")); // Raw text must exceed the 4096-byte floor Go's bufio.Scanner starts at // regardless of the configured limit (see legacy-migration-apply.unit.test.ts's // equivalent case for the exact same 4096-byte floor). - writeFileSync(join(dir, "big.sql"), `insert into t values ('${"x".repeat(5000)}');`); const { session, queries } = fakeSession(); const out = mockOutput(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "100b"; - return run(session, dir, ["big.sql"], out).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - expect(queries.some((q) => q.sql.includes("insert into t"))).toBe(false); - rmSync(dir, { recursive: true, force: true }); - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - }), - ), + return withTempDirectory("legacy-seed-scanner-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString( + path.join(dir, "big.sql"), + `insert into t values ('${"x".repeat(5000)}');`, + ); + const exit = yield* legacyApplySeedFiles( + session, + fs, + path, + dir, + { enabled: true, sqlPaths: ["big.sql"] }, + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(queries.some((q) => q.sql.includes("insert into t"))).toBe(false); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))), ); }, ); @@ -147,8 +169,6 @@ describe("legacyApplySeedFiles scanner buffer size", () => { describe("legacyApplySeedFiles stepped-down session", () => { it.effect("restores the role right after a reset and before the seed_files upsert", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-seed-")); - writeFileSync(join(dir, "seed.sql"), "set role r;\nreset role;\ninsert into t values (1);"); const calls: Array<string> = []; const session: LegacyDbSession = { restoreRoleSql: "SET SESSION ROLE postgres", @@ -167,21 +187,30 @@ describe("legacyApplySeedFiles stepped-down session", () => { queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), }; const out = mockOutput(); - return run(session, dir, ["seed.sql"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - const resetAt = calls.indexOf("reset role"); - const upsertAt = calls.findIndex((sql) => - sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - // Injected immediately after the reset, so the following insert (and - // everything else in the file) runs as postgres again. - expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); - expect(upsertAt).toBeGreaterThan(resetAt); - expect(out.stderrText).not.toContain("WARN:"); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withTempDirectory("legacy-seed-", (dir, fs, path) => + Effect.gen(function* () { + yield* fs.writeFileString( + path.join(dir, "seed.sql"), + "set role r;\nreset role;\ninsert into t values (1);", + ); + yield* legacyApplySeedFiles( + session, + fs, + path, + dir, + { enabled: true, sqlPaths: ["seed.sql"] }, + {}, + ); + const resetAt = calls.indexOf("reset role"); + const upsertAt = calls.findIndex((sql) => + sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + // Injected immediately after the reset, so the following insert (and + // everything else in the file) runs as postgres again. + expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); + expect(upsertAt).toBeGreaterThan(resetAt); + expect(out.stderrText).not.toContain("WARN:"); + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, out.layer))), ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts b/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts index 086d84b4e6..d20bebf7ec 100644 --- a/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-sql-files-glob.unit.test.ts @@ -1,12 +1,38 @@ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { legacySqlFilesGlob } from "./legacy-sql-files-glob.ts"; +const tempRoot = useLegacyTempWorkdir("legacy-sql-glob-"); + +const writeFile = ( + fs: FileSystem.FileSystem, + path: Path.Path, + workdir: string, + relativePath: string, + content: string, +) => { + const fullPath = path.join(workdir, relativePath); + return fs + .makeDirectory(path.dirname(fullPath), { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(fullPath, content))); +}; + +const withFixture = <A>( + use: ( + dir: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect<A, Error, FileSystem.FileSystem | Path.Path>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* use(tempRoot.current, fs, path); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie); + const run = (patterns: ReadonlyArray<string>, workdir: string) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -21,15 +47,12 @@ describe("legacySqlFilesGlob", () => { // `fs.Glob`/`afero.Glob` resolve a no-metacharacter pattern via `Lstat`, which // errors on an empty path — an empty `schema_paths`/`sql_paths` entry (e.g. // `schema_paths = [""]`) always yields no matches, never the workdir itself. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-empty-")); - return run([""], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual(["no files matched pattern: "]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir) => + Effect.gen(function* () { + const result = yield* run([""], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: "]); + }), ); }, ); @@ -41,22 +64,17 @@ describe("legacySqlFilesGlob", () => { // each child from its parent's `ReadDir` entry (`os.ReadDir`'s Lstat-based // `DirEntry`) and never re-`Stat`s through it — so a symlinked `.sql` file is // never included, regardless of what it points to. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-symlink-file-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "real.sql"), "select 1;"); - const outsideDir = join(dir, "outside"); - mkdirSync(outsideDir); - writeFileSync(join(outsideDir, "evil.sql"), "select 2;"); - symlinkSync(join(outsideDir, "evil.sql"), join(schemasDir, "linked.sql")); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const schemasDir = path.join(dir, "schemas"); + const outsideDir = path.join(dir, "outside"); + yield* writeFile(fs, path, dir, "schemas/real.sql", "select 1;"); + yield* writeFile(fs, path, dir, "outside/evil.sql", "select 2;"); + yield* fs.symlink(path.join(outsideDir, "evil.sql"), path.join(schemasDir, "linked.sql")); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -64,22 +82,17 @@ describe("legacySqlFilesGlob", () => { it.effect( "does not recurse into a symlinked subdirectory below a matched directory (Go WalkDir parity)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-symlink-dir-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "real.sql"), "select 1;"); - const outsideSubdir = join(dir, "outside-subdir"); - mkdirSync(outsideSubdir); - writeFileSync(join(outsideSubdir, "nested.sql"), "select 3;"); - symlinkSync(outsideSubdir, join(schemasDir, "linked-dir")); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const schemasDir = path.join(dir, "schemas"); + const outsideSubdir = path.join(dir, "outside-subdir"); + yield* writeFile(fs, path, dir, "schemas/real.sql", "select 1;"); + yield* writeFile(fs, path, dir, "outside-subdir/nested.sql", "select 3;"); + yield* fs.symlink(outsideSubdir, path.join(schemasDir, "linked-dir")); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -99,22 +112,21 @@ describe("legacySqlFilesGlob", () => { // This module never `process.chdir`s, so the real stat needs an absolute path — but // the warning must still report the relative form, not that absolute (temp-dir) // path, or it would leak a local filesystem path Go never would. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-stat-fail-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "good.sql"), "select 1;"); - symlinkSync(join(schemasDir, "does-not-exist.sql"), join(schemasDir, "broken.sql")); - return run(["schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/good.sql"]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); - expect(result.warnings[0]).toContain("schemas/broken.sql"); - expect(result.warnings[0]).not.toContain(dir); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const schemasDir = path.join(dir, "schemas"); + yield* writeFile(fs, path, dir, "schemas/good.sql", "select 1;"); + yield* fs.symlink( + path.join(schemasDir, "does-not-exist.sql"), + path.join(schemasDir, "broken.sql"), + ); + const result = yield* run(["schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/good.sql"]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); + expect(result.warnings[0]).toContain("schemas/broken.sql"); + expect(result.warnings[0]).not.toContain(dir); + }), ); }, ); @@ -131,20 +143,20 @@ describe("legacySqlFilesGlob", () => { // (`afero.Glob`/`fs.Stat` scratch probe): a literal broken-symlink // pattern always Globs to a match and always fails the follow-up Stat — never // "no files matched pattern". - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-literal-symlink-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - symlinkSync(join(schemasDir, "does-not-exist.sql"), join(schemasDir, "broken.sql")); - return run(["schemas/broken.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); - expect(result.warnings[0]).toContain("schemas/broken.sql"); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const schemasDir = path.join(dir, "schemas"); + yield* fs.makeDirectory(schemasDir, { recursive: true }); + yield* fs.symlink( + path.join(schemasDir, "does-not-exist.sql"), + path.join(schemasDir, "broken.sql"), + ); + const result = yield* run(["schemas/broken.sql"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to stat matched file: /); + expect(result.warnings[0]).toContain("schemas/broken.sql"); + }), ); }, ); @@ -161,18 +173,15 @@ describe("legacySqlFilesGlob", () => { // component after the root slash still resolves against the filesystem ROOT, not // cwd. A canary file placed in the WORKDIR (never the real "/") proves this native // port does not fall back to treating the root component as workdir-relative. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-abs-root-")); - writeFileSync(join(dir, "__legacy_sql_glob_canary__.sql"), "select 1;"); - return run(["/*__legacy_sql_glob_canary__*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual([ - "no files matched pattern: /*__legacy_sql_glob_canary__*.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "__legacy_sql_glob_canary__.sql", "select 1;"); + const result = yield* run(["/*__legacy_sql_glob_canary__*.sql"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual([ + "no files matched pattern: /*__legacy_sql_glob_canary__*.sql", + ]); + }), ); }, ); @@ -185,20 +194,15 @@ describe("legacySqlFilesGlob", () => { // real filesystem root, not "" (which `globOne` maps to the workdir). The workdir // here contains a subdirectory that WOULD match "foo*" if (and only if) the // recursive call incorrectly fell back to reading the workdir instead of "/". - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-abs-root-nested-")); - const canaryDir = join(dir, "__legacy_sql_glob_root_canary_dir__"); - mkdirSync(canaryDir); - writeFileSync(join(canaryDir, "a.sql"), "select 1;"); - return run(["/__legacy_sql_glob_root_canary_dir__*/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual([ - "no files matched pattern: /__legacy_sql_glob_root_canary_dir__*/*.sql", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "__legacy_sql_glob_root_canary_dir__/a.sql", "select 1;"); + const result = yield* run(["/__legacy_sql_glob_root_canary_dir__*/*.sql"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual([ + "no files matched pattern: /__legacy_sql_glob_root_canary_dir__*/*.sql", + ]); + }), ); }, ); @@ -220,35 +224,31 @@ describe("legacySqlFilesGlob", () => { // so the test exercises the same branch a real Windows install takes. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-drive-root-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const dir = tempRoot.current; + const canaryFile = `${dir}/a.sql`; + yield* fs.writeFileString(canaryFile, "select 1;"); // A "C:/" drive root doesn't exist on this (non-Windows) test host, so // fake just the two calls that must resolve against it, reusing a real // file's stat info to avoid hand-rolling a `File.Info`. - const realFileInfo = yield* fs.stat(join(dir, "a.sql")); + const realFileInfo = yield* fs.stat(canaryFile); const driveRootFs: FileSystem.FileSystem = { ...fs, readDirectory: (p: string) => p === "C:/" ? Effect.succeed(["a.sql"]) : fs.readDirectory(p), stat: (p: string) => (p === "C:/a.sql" ? Effect.succeed(realFileInfo) : fs.stat(p)), }; - return yield* legacySqlFilesGlob(driveRootFs, path, ["C:/*.sql"], dir); + const result = yield* legacySqlFilesGlob(driveRootFs, path, ["C:/*.sql"], dir); + expect(result.files).toEqual(["C:/a.sql"]); + expect(result.warnings).toEqual([]); }).pipe( Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["C:/a.sql"]); - expect(result.warnings).toEqual([]); - }), - ), Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(dir, { recursive: true, force: true }); - }), + Effect.sync(() => + Object.defineProperty(process, "platform", { value: originalPlatform }), + ), ), ); }, @@ -267,24 +267,23 @@ describe("legacySqlFilesGlob", () => { // the drive-root test above. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-win-warn-")); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - return yield* legacySqlFilesGlob(fs, path, ["C:\\schemas\\*.sql"], dir); + const result = yield* legacySqlFilesGlob( + fs, + path, + ["C:\\schemas\\*.sql"], + tempRoot.current, + ); + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: C:\\schemas\\*.sql"]); }).pipe( Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual(["no files matched pattern: C:\\schemas\\*.sql"]); - }), - ), Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(dir, { recursive: true, force: true }); - }), + Effect.sync(() => + Object.defineProperty(process, "platform", { value: originalPlatform }), + ), ), ); }, @@ -308,12 +307,12 @@ describe("legacySqlFilesGlob", () => { // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); - const scratchDir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-win-sort-")); - const canaryFile = join(scratchDir, "canary.sql"); - writeFileSync(canaryFile, "select 1;"); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const scratchDir = tempRoot.current; + const canaryFile = `${scratchDir}/canary.sql`; + yield* fs.writeFileString(canaryFile, "select 1;"); const fileInfo = yield* fs.stat(canaryFile); const workdir = "/workdir"; const winFs: FileSystem.FileSystem = { @@ -328,20 +327,15 @@ describe("legacySqlFilesGlob", () => { ? Effect.succeed(fileInfo) : fs.stat(p), }; - return yield* legacySqlFilesGlob(winFs, path, ["a*/x.sql"], workdir); + const result = yield* legacySqlFilesGlob(winFs, path, ["a*/x.sql"], workdir); + expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); + expect(result.warnings).toEqual([]); }).pipe( Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); - expect(result.warnings).toEqual([]); - }), - ), Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(scratchDir, { recursive: true, force: true }); - }), + Effect.sync(() => + Object.defineProperty(process, "platform", { value: originalPlatform }), + ), ), ); }, @@ -358,18 +352,13 @@ describe("legacySqlFilesGlob", () => { // Verified empirically: a scratch probe calling // `config.Glob{"<dir>/"}.SQLFiles(...)` on a real trailing-slash directory returns // the single-slash path, not a doubled one. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-trailing-slash-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas/"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + const result = yield* run(["schemas/"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -388,19 +377,14 @@ describe("legacySqlFilesGlob", () => { // `./`-prefixed path would never match an already-recorded Go-CLI key. Verified // empirically: `path.Join(".", "foo.sql")` and a real `fs.WalkDir` rooted at `.` both // drop the `./` prefix entirely. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-dot-root-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - const nestedDir = join(dir, "nested"); - mkdirSync(nestedDir); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - return run(["."], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["a.sql", "nested/b.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "a.sql", "select 1;"); + yield* writeFile(fs, path, dir, "nested/b.sql", "select 2;"); + const result = yield* run(["."], dir); + expect(result.files).toEqual(["a.sql", "nested/b.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -415,18 +399,13 @@ describe("legacySqlFilesGlob", () => { // walk over its children must record `schemas/a.sql`, not `nested/../schemas/a.sql`. // Verified empirically: `path.Join("/tmp/x/../schemas", "a.sql")` and Node's // `path.join("/tmp/x/../schemas", "a.sql")` both clean to `/tmp/schemas/a.sql`. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-dotdot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["nested/../schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + const result = yield* run(["nested/../schemas"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -443,18 +422,13 @@ describe("legacySqlFilesGlob", () => { // `schemas/a.sql`, not a raw `schemas/./a.sql` concatenation. Verified empirically: // a scratch `afero.Glob(fs, ".../tmp/./schemas/*.sql")` probe against a real // filesystem returns the cleaned path. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-direct-dot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas/./*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + const result = yield* run(["schemas/./*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -470,18 +444,13 @@ describe("legacySqlFilesGlob", () => { // recorded path is the `supabase_migrations.seed_files.path` hash key, so leaving it // uncleaned would make a TS-resolved match fail to line up with an already-recorded // Go-CLI key and re-run/re-record the seed. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-direct-dotdot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["nested/../schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + const result = yield* run(["nested/../schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -495,18 +464,13 @@ describe("legacySqlFilesGlob", () => { // (`"schemas//a.sql"`). `filepath.Join`/`path.join` collapse doubled slashes // regardless of where they came from, so the recorded match must be the // single-slash `schemas/a.sql`. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-direct-doubled-slash-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas//*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + const result = yield* run(["schemas//*.sql"], dir); + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); @@ -523,32 +487,21 @@ describe("legacySqlFilesGlob", () => { // enough here (that's the earlier symlink test), so exercise the `fs.stat` failure // path itself by removing the file the instant after `readDirectory` returns it, via // a `FileSystem` layer that deletes on first `stat` call for that path. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-stat-race-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - const racyFile = join(schemasDir, "racy.sql"); - writeFileSync(racyFile, "select 1;"); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const dir = tempRoot.current; + const racyFile = path.join(dir, "schemas", "racy.sql"); + yield* writeFile(fs, path, dir, "schemas/racy.sql", "select 1;"); const racyFs: FileSystem.FileSystem = { ...fs, stat: (p: string) => - p === racyFile - ? Effect.sync(() => rmSync(racyFile)).pipe(Effect.andThen(fs.stat(p))) - : fs.stat(p), + p === racyFile ? fs.remove(racyFile).pipe(Effect.andThen(fs.stat(p))) : fs.stat(p), }; - return yield* legacySqlFilesGlob(racyFs, path, ["schemas"], dir); - }).pipe( - Effect.provide(BunServices.layer), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/racy.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + const result = yield* legacySqlFilesGlob(racyFs, path, ["schemas"], dir); + expect(result.files).toEqual(["schemas/racy.sql"]); + expect(result.warnings).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)); }, ); @@ -574,61 +527,39 @@ describe("legacySqlFilesGlob", () => { // could equally have been the now-missing subdirectory, and must fail the same way an // unreadable still-present directory does, not silently vanish along with the files it // may have held. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-dir-race-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - const nestedDir = join(schemasDir, "nested"); - mkdirSync(nestedDir); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const dir = tempRoot.current; + const nestedDir = path.join(dir, "schemas", "nested"); + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + yield* writeFile(fs, path, dir, "schemas/nested/b.sql", "select 2;"); const racyFs: FileSystem.FileSystem = { ...fs, stat: (p: string) => p === nestedDir - ? Effect.sync(() => rmSync(nestedDir, { recursive: true })).pipe( - Effect.andThen(fs.stat(p)), - ) + ? fs.remove(nestedDir, { recursive: true }).pipe(Effect.andThen(fs.stat(p))) : fs.stat(p), }; - return yield* legacySqlFilesGlob(racyFs, path, ["schemas"], dir); - }).pipe( - Effect.provide(BunServices.layer), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - // This `stat` failure stands in for the second `ReadDir` Go's own `fs.WalkDir` - // would issue on the vanished directory — whose error, like every other Go - // filesystem error here, embeds the workdir-relative path, not this port's - // absolute stand-in syscall path. - expect(result.warnings[0]).toContain("schemas/nested"); - expect(result.warnings[0]).not.toContain(dir); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + const result = yield* legacySqlFilesGlob(racyFs, path, ["schemas"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas/nested"); + expect(result.warnings[0]).not.toContain(dir); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.effect("still expands a real (non-symlinked) nested directory recursively", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-nested-")); - const schemasDir = join(dir, "schemas"); - const nestedDir = join(schemasDir, "nested"); - mkdirSync(nestedDir, { recursive: true }); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql", "schemas/nested/b.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + yield* writeFile(fs, path, dir, "schemas/nested/b.sql", "select 2;"); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual(["schemas/a.sql", "schemas/nested/b.sql"]); + expect(result.warnings).toEqual([]); + }), ); }); @@ -648,27 +579,19 @@ describe("legacySqlFilesGlob", () => { // workdir-relative matched directory (`schemas`), never an absolute one. This // module never `process.chdir`s, so the real read needs an absolute path, but the // warning must still report the relative form. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-fail-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - chmodSync(schemasDir, 0o000); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - expect(result.warnings[0]).toContain("schemas"); - expect(result.warnings[0]).not.toContain(dir); - }), - ), - Effect.ensuring( - Effect.sync(() => { - chmodSync(schemasDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const schemasDir = path.join(dir, "schemas"); + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + yield* fs.chmod(schemasDir, 0o000); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas"); + expect(result.warnings[0]).not.toContain(dir); + yield* fs.chmod(schemasDir, 0o755); + }), ); }, ); @@ -683,29 +606,20 @@ describe("legacySqlFilesGlob", () => { // way. The matched root ("schemas") itself is readable; only "schemas/nested" is // not, so the warning must report "schemas/nested", never the workdir's absolute // temp-dir path. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-fail-nested-")); - const schemasDir = join(dir, "schemas"); - const nestedDir = join(schemasDir, "nested"); - mkdirSync(nestedDir, { recursive: true }); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - chmodSync(nestedDir, 0o000); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - expect(result.warnings[0]).toContain("schemas/nested"); - expect(result.warnings[0]).not.toContain(dir); - }), - ), - Effect.ensuring( - Effect.sync(() => { - chmodSync(nestedDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const nestedDir = path.join(dir, "schemas", "nested"); + yield* writeFile(fs, path, dir, "schemas/a.sql", "select 1;"); + yield* writeFile(fs, path, dir, "schemas/nested/b.sql", "select 2;"); + yield* fs.chmod(nestedDir, 0o000); + const result = yield* run(["schemas"], dir); + expect(result.files).toEqual([]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + expect(result.warnings[0]).toContain("schemas/nested"); + expect(result.warnings[0]).not.toContain(dir); + yield* fs.chmod(nestedDir, 0o755); + }), ); }, ); @@ -717,28 +631,18 @@ describe("legacySqlFilesGlob", () => { // failure on one match doesn't stop the loop over the REST of the matches/patterns; // whether it's ultimately fatal is the caller's decision (`legacyApplySchemaFiles`'s // `len(declared) == 0` gate), not this function's. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-fail-partial-")); - const goodDir = join(dir, "good"); - const badDir = join(dir, "bad"); - mkdirSync(goodDir); - mkdirSync(badDir); - writeFileSync(join(goodDir, "a.sql"), "select 1;"); - writeFileSync(join(badDir, "b.sql"), "select 2;"); - chmodSync(badDir, 0o000); - return run(["good", "bad"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["good/a.sql"]); - expect(result.warnings).toHaveLength(1); - expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - }), - ), - Effect.ensuring( - Effect.sync(() => { - chmodSync(badDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + const badDir = path.join(dir, "bad"); + yield* writeFile(fs, path, dir, "good/a.sql", "select 1;"); + yield* writeFile(fs, path, dir, "bad/b.sql", "select 2;"); + yield* fs.chmod(badDir, 0o000); + const result = yield* run(["good", "bad"], dir); + expect(result.files).toEqual(["good/a.sql"]); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); + yield* fs.chmod(badDir, 0o755); + }), ); }, ); @@ -757,17 +661,17 @@ describe("legacySqlFilesGlob", () => { // Go's guaranteed order — to prove the walk sorts them back (`utf8Compare`) // before iterating, rather than trusting raw (here: adversarial) enumeration // order. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-walk-order-")); - const schemasDir = join(dir, "schemas"); - const aaaDir = join(schemasDir, "aaa"); - const bbbDir = join(schemasDir, "bbb"); - mkdirSync(aaaDir, { recursive: true }); - mkdirSync(bbbDir, { recursive: true }); - chmodSync(aaaDir, 0o000); - chmodSync(bbbDir, 0o000); return Effect.gen(function* () { const realFs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const dir = tempRoot.current; + const schemasDir = path.join(dir, "schemas"); + const aaaDir = path.join(schemasDir, "aaa"); + const bbbDir = path.join(schemasDir, "bbb"); + yield* realFs.makeDirectory(aaaDir, { recursive: true }); + yield* realFs.makeDirectory(bbbDir, { recursive: true }); + yield* realFs.chmod(aaaDir, 0o000); + yield* realFs.chmod(bbbDir, 0o000); const reorderedFs: FileSystem.FileSystem = { ...realFs, readDirectory: (p, opts) => @@ -784,16 +688,9 @@ describe("legacySqlFilesGlob", () => { // "bbb" is never even attempted. expect(result.warnings[0]).toContain("schemas/aaa"); expect(result.warnings[0]).not.toContain("schemas/bbb"); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring( - Effect.sync(() => { - chmodSync(aaaDir, 0o755); - chmodSync(bbbDir, 0o755); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + yield* realFs.chmod(aaaDir, 0o755); + yield* realFs.chmod(bbbDir, 0o755); + }).pipe(Effect.provide(BunServices.layer)); }, ); @@ -809,19 +706,14 @@ describe("legacySqlFilesGlob", () => { // exclamation mark's code unit (0xFF01) — the opposite order. Verified empirically // against a real Go `sort.Strings` call: it places the fullwidth-exclamation file // first. - const dir = mkdtempSync(join(tmpdir(), "legacy-sql-glob-utf8-sort-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 - writeFileSync(join(schemasDir, "!.sql"), "select 2;"); // ! - return run(["schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), + return withFixture((dir, fs, path) => + Effect.gen(function* () { + yield* writeFile(fs, path, dir, "schemas/\u{1F600}.sql", "select 1;"); // 😀 + yield* writeFile(fs, path, dir, "schemas/!.sql", "select 2;"); // ! + const result = yield* run(["schemas/*.sql"], dir); + expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); + expect(result.warnings).toEqual([]); + }), ); }, ); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts index 476279c58a..4327839316 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.ts @@ -1,7 +1,4 @@ -import { rm } from "node:fs/promises"; -import { resolve, sep } from "node:path"; - -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; @@ -73,27 +70,17 @@ import type { LegacyContainerIdName } from "./legacy-docker-lifecycle.ts"; * empty (would otherwise resolve to the staging root itself and wipe every project's * secrets). */ -export function legacyCleanupStartSecrets( +export const legacyCleanupStartSecrets = Effect.fnUntraced(function* ( containers: ReadonlyArray<LegacyContainerIdName>, fallbackWorkdir: string, -): Effect.Effect<void> { - return Effect.tryPromise(() => - Promise.all( - containers.map((container) => { - const workdir = container.workdir.length > 0 ? container.workdir : fallbackWorkdir; - const stagingRoot = resolve(workdir, "supabase", ".temp", "start-secrets"); - const target = resolve(stagingRoot, container.name); - if (target === stagingRoot || !target.startsWith(stagingRoot + sep)) { - return Promise.resolve(); - } - return rm(target, { - recursive: true, - force: true, - }); - }), - ), - ).pipe( - Effect.asVoid, - Effect.orElseSucceed(() => undefined), - ); -} +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const container of containers) { + const workdir = container.workdir.length > 0 ? container.workdir : fallbackWorkdir; + const stagingRoot = path.resolve(workdir, "supabase", ".temp", "start-secrets"); + const target = path.resolve(stagingRoot, container.name); + if (target === stagingRoot || !target.startsWith(stagingRoot + path.sep)) continue; + yield* fs.remove(target, { recursive: true, force: true }).pipe(Effect.ignore); + } +}); diff --git a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts index 5b934925c1..599794630d 100644 --- a/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-start-secrets-cleanup.unit.test.ts @@ -1,39 +1,39 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Path } from "effect"; import { legacyCleanupStartSecrets } from "./legacy-start-secrets-cleanup.ts"; +const withTempDirectory = <A>( + prefix: string, + use: ( + directory: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect<A, Error, FileSystem.FileSystem | Path.Path>, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectory({ prefix }); + return yield* Effect.acquireUseRelease( + Effect.succeed(directory), + (root) => use(root, fs, path), + (root) => fs.remove(root, { recursive: true, force: true }).pipe(Effect.ignore), + ); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie); + describe("legacyCleanupStartSecrets", () => { it.effect("removes a NAMED container's secret directory keyed off container.name", () => { - const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); - const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(secretDir, { recursive: true }); - yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); - yield* legacyCleanupStartSecrets( - [{ id: "abc123", name: "supabase_kong_demo", workdir: "" }], - workdir, - ); - expect(yield* fs.exists(secretDir)).toBe(false); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); - }); - - it.effect( - "falls back to fallbackWorkdir when a container carries no com.supabase.cli.workdir label", - () => { - const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); - const secretDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_demo"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; + return withTempDirectory("legacy-start-secrets-cleanup-", (workdir, fs, path) => + Effect.gen(function* () { + const secretDir = path.join( + workdir, + "supabase", + ".temp", + "start-secrets", + "supabase_kong_demo", + ); yield* fs.makeDirectory(secretDir, { recursive: true }); yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); yield* legacyCleanupStartSecrets( @@ -41,39 +41,64 @@ describe("legacyCleanupStartSecrets", () => { workdir, ); expect(yield* fs.exists(secretDir)).toBe(false); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + }), + ); + }); + + it.effect( + "falls back to fallbackWorkdir when a container carries no com.supabase.cli.workdir label", + () => { + return withTempDirectory("legacy-start-secrets-cleanup-", (workdir, fs, path) => + Effect.gen(function* () { + const secretDir = path.join( + workdir, + "supabase", + ".temp", + "start-secrets", + "supabase_kong_demo", + ); + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_kong_demo", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + }), + ); }, ); it.effect("prefers a container's OWN com.supabase.cli.workdir label over fallbackWorkdir", () => { - const ownWorkdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-own-")); - const otherWorkdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-other-")); - const secretDir = join(ownWorkdir, "supabase", ".temp", "start-secrets", "supabase_db_demo"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(secretDir, { recursive: true }); - yield* fs.writeFileString(path.join(secretDir, "secret-0"), "db-secret"); - yield* legacyCleanupStartSecrets( - [{ id: "abc123", name: "supabase_db_demo", workdir: ownWorkdir }], - otherWorkdir, - ); - expect(yield* fs.exists(secretDir)).toBe(false); - rmSync(ownWorkdir, { recursive: true, force: true }); - rmSync(otherWorkdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + return withTempDirectory("legacy-start-secrets-cleanup-own-", (ownWorkdir, fs, path) => + withTempDirectory("legacy-start-secrets-cleanup-other-", (otherWorkdir) => + Effect.gen(function* () { + const secretDir = path.join( + ownWorkdir, + "supabase", + ".temp", + "start-secrets", + "supabase_db_demo", + ); + yield* fs.makeDirectory(secretDir, { recursive: true }); + yield* fs.writeFileString(path.join(secretDir, "secret-0"), "db-secret"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "supabase_db_demo", workdir: ownWorkdir }], + otherWorkdir, + ); + expect(yield* fs.exists(secretDir)).toBe(false); + }), + ), + ); }); it.effect("never fails when nothing was ever staged for a container", () => { - const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); - return Effect.gen(function* () { - yield* legacyCleanupStartSecrets( + return withTempDirectory("legacy-start-secrets-cleanup-", (workdir) => + legacyCleanupStartSecrets( [{ id: "abc123", name: "supabase_realtime_demo", workdir: "" }], workdir, - ); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + ), + ); }); it.effect( @@ -83,20 +108,18 @@ describe("legacyCleanupStartSecrets", () => { // matched the caller's project-label filter — external metadata, not something this // process generated. A crafted name containing `..` segments must never be able to walk // `rm -rf` outside `start-secrets/` and onto an unrelated host directory. - const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); - const canary = join(workdir, "important"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(canary, { recursive: true }); - yield* fs.writeFileString(path.join(canary, "do-not-delete"), "canary"); - yield* legacyCleanupStartSecrets( - [{ id: "abc123", name: "../../important", workdir: "" }], - workdir, - ); - expect(yield* fs.exists(canary)).toBe(true); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + return withTempDirectory("legacy-start-secrets-cleanup-", (workdir, fs, path) => + Effect.gen(function* () { + const canary = path.join(workdir, "important"); + yield* fs.makeDirectory(canary, { recursive: true }); + yield* fs.writeFileString(path.join(canary, "do-not-delete"), "canary"); + yield* legacyCleanupStartSecrets( + [{ id: "abc123", name: "../../important", workdir: "" }], + workdir, + ); + expect(yield* fs.exists(canary)).toBe(true); + }), + ); }, ); @@ -104,17 +127,15 @@ describe("legacyCleanupStartSecrets", () => { // Degenerate case: an empty `name` would otherwise resolve to the staging root itself // (`<workdir>/supabase/.temp/start-secrets`) and wipe every project's staged secrets in // one call, not just this one container's. - const workdir = mkdtempSync(join(tmpdir(), "legacy-start-secrets-cleanup-")); - const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); - const otherProjectSecretDir = join(stagingRoot, "supabase_kong_other"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fs.makeDirectory(otherProjectSecretDir, { recursive: true }); - yield* fs.writeFileString(path.join(otherProjectSecretDir, "secret-0"), "kong-secret"); - yield* legacyCleanupStartSecrets([{ id: "abc123", name: "", workdir: "" }], workdir); - expect(yield* fs.exists(otherProjectSecretDir)).toBe(true); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(BunServices.layer)); + return withTempDirectory("legacy-start-secrets-cleanup-", (workdir, fs, path) => + Effect.gen(function* () { + const stagingRoot = path.join(workdir, "supabase", ".temp", "start-secrets"); + const otherProjectSecretDir = path.join(stagingRoot, "supabase_kong_other"); + yield* fs.makeDirectory(otherProjectSecretDir, { recursive: true }); + yield* fs.writeFileString(path.join(otherProjectSecretDir, "secret-0"), "kong-secret"); + yield* legacyCleanupStartSecrets([{ id: "abc123", name: "", workdir: "" }], workdir); + expect(yield* fs.exists(otherProjectSecretDir)).toBe(true); + }), + ); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-status-pretty.ts b/apps/cli/src/legacy/shared/legacy-status-pretty.ts index 90347bdc61..03aa195a2c 100644 --- a/apps/cli/src/legacy/shared/legacy-status-pretty.ts +++ b/apps/cli/src/legacy/shared/legacy-status-pretty.ts @@ -104,7 +104,7 @@ function buildGroups( * East-Asian-Width dependency for a 5-value constant table). */ function displayWidth(text: string): number { - return [...text].length; + return Array.from(text).length; } /** Go-rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ diff --git a/apps/cli/src/legacy/shared/legacy-status-values.ts b/apps/cli/src/legacy/shared/legacy-status-values.ts index 31d18e44ac..ada5d51f4e 100644 --- a/apps/cli/src/legacy/shared/legacy-status-values.ts +++ b/apps/cli/src/legacy/shared/legacy-status-values.ts @@ -1,4 +1,5 @@ import type { ProjectConfig } from "@supabase/config"; +import { Effect, FileSystem, Path } from "effect"; import { dockerfileServiceImage } from "../../shared/services/dockerfile-images.ts"; import { legacyServiceContainerIds } from "./legacy-docker-ids.ts"; @@ -284,9 +285,9 @@ export function legacyResolveStatusLocalState( config: ProjectConfig, hostname: string, workdir: string, - projectEnvValues: Readonly<Record<string, string>> | undefined = undefined, + projectEnvValues: Readonly<Record<string, string>>, /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ - document: Readonly<Record<string, unknown>> | undefined = undefined, + document?: Readonly<Record<string, unknown>>, /** * An already-resolved {@link legacyResolveLocalConfigValues} result to reuse * instead of re-deriving one. Callers that resolved `local` earlier in the @@ -303,65 +304,73 @@ export function legacyResolveStatusLocalState( * sees the same value. */ precomputedLocal?: LegacyLocalConfigValues, -): LegacyStatusLocalState { - const local = - precomputedLocal ?? - legacyResolveLocalConfigValues(config, hostname, workdir, projectEnvValues, document); +): Effect.Effect<LegacyStatusLocalState, Error, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const local = + precomputedLocal ?? + (yield* legacyResolveLocalConfigValues( + config, + hostname, + workdir, + projectEnvValues, + document, + )); - const apiEnabled = legacyEnvOverrideBool( - "SUPABASE_API_ENABLED", - config.api.enabled, - "api.enabled", - projectEnvValues, - ); - const studioSectionEnabled = legacyEnvOverrideBool( - "SUPABASE_STUDIO_ENABLED", - config.studio.enabled, - "studio.enabled", - projectEnvValues, - ); - const authSectionEnabled = legacyEnvOverrideBool( - "SUPABASE_AUTH_ENABLED", - config.auth.enabled, - "auth.enabled", - projectEnvValues, - ); - const inbucketSectionEnabled = legacyEnvOverrideBool( - "SUPABASE_LOCAL_SMTP_ENABLED", - config.local_smtp.enabled, - "local_smtp.enabled", - projectEnvValues, - ); - const storageSectionEnabled = legacyEnvOverrideBool( - "SUPABASE_STORAGE_ENABLED", - config.storage.enabled, - "storage.enabled", - projectEnvValues, - ); - const edgeRuntimeEnabled = legacyEnvOverrideBool( - "SUPABASE_EDGE_RUNTIME_ENABLED", - config.edge_runtime.enabled, - "edge_runtime.enabled", - projectEnvValues, - ); - const storageS3ProtocolEnabled = legacyEnvOverrideBool( - "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", - config.storage.s3_protocol.enabled, - "storage.s3_protocol.enabled", - projectEnvValues, - ); + const apiEnabled = legacyEnvOverrideBool( + "SUPABASE_API_ENABLED", + config.api.enabled, + "api.enabled", + projectEnvValues, + ); + const studioSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STUDIO_ENABLED", + config.studio.enabled, + "studio.enabled", + projectEnvValues, + ); + const authSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_AUTH_ENABLED", + config.auth.enabled, + "auth.enabled", + projectEnvValues, + ); + const inbucketSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_LOCAL_SMTP_ENABLED", + config.local_smtp.enabled, + "local_smtp.enabled", + projectEnvValues, + ); + const storageSectionEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_ENABLED", + config.storage.enabled, + "storage.enabled", + projectEnvValues, + ); + const edgeRuntimeEnabled = legacyEnvOverrideBool( + "SUPABASE_EDGE_RUNTIME_ENABLED", + config.edge_runtime.enabled, + "edge_runtime.enabled", + projectEnvValues, + ); + const storageS3ProtocolEnabled = legacyEnvOverrideBool( + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + config.storage.s3_protocol.enabled, + "storage.s3_protocol.enabled", + projectEnvValues, + ); - return { - config, - local, - apiEnabled, - studioSectionEnabled, - authSectionEnabled, - inbucketSectionEnabled, - storageSectionEnabled, - edgeRuntimeEnabled, - storageS3ProtocolEnabled, - }; + return { + config, + local, + apiEnabled, + studioSectionEnabled, + authSectionEnabled, + inbucketSectionEnabled, + storageSectionEnabled, + edgeRuntimeEnabled, + storageS3ProtocolEnabled, + }; + }); } /** @@ -489,17 +498,19 @@ export function legacyStatusValues( excluded: ReadonlyArray<string>, overrides: ReadonlyMap<string, string>, workdir: string, - projectEnvValues: Readonly<Record<string, string>> | undefined = undefined, + projectEnvValues: Readonly<Record<string, string>>, /** `LoadedProjectConfig.document` — see {@link legacyResolveLocalConfigValues}'s doc comment. */ - document: Readonly<Record<string, unknown>> | undefined = undefined, -): LegacyStatusValuesResult { - const localState = legacyResolveStatusLocalState( - config, - hostname, - workdir, - projectEnvValues, - document, - ); - const state = legacyGateStatusState(localState, containerIds, excluded); - return legacyStatusValuesFromState(state, overrides); + document?: Readonly<Record<string, unknown>>, +): Effect.Effect<LegacyStatusValuesResult, Error, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const localState = yield* legacyResolveStatusLocalState( + config, + hostname, + workdir, + projectEnvValues, + document, + ); + const state = legacyGateStatusState(localState, containerIds, excluded); + return legacyStatusValuesFromState(state, overrides); + }); } diff --git a/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts b/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts index 71935639c5..bb81ac3502 100644 --- a/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-status-values.unit.test.ts @@ -1,14 +1,18 @@ import { ProjectConfigSchema, type ProjectConfig } from "@supabase/config"; -import { Schema } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { legacyShortContainerImageName, legacyStatusContainerIds, - legacyStatusValues, + legacyStatusValues as resolveStatusValues, type LegacyStatusContainerIds, } from "./legacy-status-values.ts"; +const runStatusValues = (...args: Parameters<typeof resolveStatusValues>) => + resolveStatusValues(...args).pipe(Effect.provide(BunServices.layer), Effect.runSync); + const decodeConfig = Schema.decodeUnknownSync(ProjectConfigSchema); function baseConfig(overrides: Record<string, unknown> = {}): ProjectConfig { @@ -40,13 +44,14 @@ describe("legacyStatusValues", () => { storage: { enabled: false }, edge_runtime: { enabled: false }, }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(Object.keys(values)).toEqual(["DB_URL"]); expect(values.DB_URL).toContain("postgresql://postgres:postgres@127.0.0.1"); @@ -54,76 +59,82 @@ describe("legacyStatusValues", () => { describe("api / kong gating", () => { it("includes API_URL when api.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.API_URL).toBeDefined(); }); it("omits API_URL when api.enabled is false", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.API_URL).toBeUndefined(); }); it("omits API_URL when the kong container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.kong], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.API_URL).toBeUndefined(); }); it("omits API_URL when the kong image short name is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["kong"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.API_URL).toBeUndefined(); }); it("omits REST/GraphQL when kong is disabled even though postgrest is enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.REST_URL).toBeUndefined(); expect(values.GRAPHQL_URL).toBeUndefined(); }); it("omits REST/GraphQL when only the rest container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.rest], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -131,26 +142,28 @@ describe("legacyStatusValues", () => { }); it("includes REST/GraphQL when kong and postgrest are both enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.REST_URL).toBeDefined(); expect(values.GRAPHQL_URL).toBeDefined(); }); it("omits REST/GraphQL when the postgrest image short name is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["postgrest"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.API_URL).toBeDefined(); expect(values.REST_URL).toBeUndefined(); @@ -160,51 +173,55 @@ describe("legacyStatusValues", () => { describe("functions gating", () => { it("includes FUNCTIONS_URL when kong and edge_runtime are both enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.FUNCTIONS_URL).toBeDefined(); }); it("omits FUNCTIONS_URL when edge_runtime.enabled is false", () => { const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when kong is disabled even though edge_runtime is enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); it("omits FUNCTIONS_URL when the edge_runtime container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.edgeRuntime], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -212,13 +229,14 @@ describe("legacyStatusValues", () => { it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile's // build alias (`edgeruntime`) — the short name Go matches against is the former. - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["edge-runtime"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.FUNCTIONS_URL).toBeUndefined(); }); @@ -226,88 +244,95 @@ describe("legacyStatusValues", () => { describe("studio / mcp gating", () => { it("includes STUDIO_URL when studio.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STUDIO_URL).toBeDefined(); }); it("omits STUDIO_URL when studio.enabled is false", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STUDIO_URL).toBeUndefined(); }); it("omits STUDIO_URL when the studio container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.studio], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STUDIO_URL).toBeUndefined(); }); it("omits STUDIO_URL when the studio image short name is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["studio"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STUDIO_URL).toBeUndefined(); }); it("includes MCP_URL only when both kong and studio are enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MCP_URL).toBeDefined(); }); it("omits MCP_URL when kong is disabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MCP_URL).toBeUndefined(); }); it("omits MCP_URL when studio is disabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MCP_URL).toBeUndefined(); }); @@ -315,13 +340,14 @@ describe("legacyStatusValues", () => { describe("auth gating", () => { it("includes all 5 auth fields when auth.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.PUBLISHABLE_KEY).toBeDefined(); expect(values.SECRET_KEY).toBeDefined(); @@ -332,13 +358,14 @@ describe("legacyStatusValues", () => { it("omits all 5 auth fields when auth.enabled is false", () => { const config = baseConfig({ auth: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); expect(values.SECRET_KEY).toBeUndefined(); @@ -348,25 +375,27 @@ describe("legacyStatusValues", () => { }); it("omits all 5 auth fields when the auth container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.auth], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); it("omits all 5 auth fields when the gotrue image short name is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["gotrue"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.PUBLISHABLE_KEY).toBeUndefined(); }); @@ -374,13 +403,14 @@ describe("legacyStatusValues", () => { describe("inbucket/mailpit gating", () => { it("includes MAILPIT_URL and the deprecated INBUCKET_URL alias when local_smtp.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MAILPIT_URL).toBeDefined(); expect(values.INBUCKET_URL).toBe(values.MAILPIT_URL); @@ -388,38 +418,41 @@ describe("legacyStatusValues", () => { it("omits MAILPIT_URL/INBUCKET_URL when local_smtp.enabled is false", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MAILPIT_URL).toBeUndefined(); expect(values.INBUCKET_URL).toBeUndefined(); }); it("omits MAILPIT_URL/INBUCKET_URL when the inbucket container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.inbucket], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MAILPIT_URL).toBeUndefined(); }); it("omits MAILPIT_URL/INBUCKET_URL when the mailpit image short name is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["mailpit"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.MAILPIT_URL).toBeUndefined(); }); @@ -427,13 +460,14 @@ describe("legacyStatusValues", () => { describe("storage / s3 gating", () => { it("includes all 4 storage S3 fields when storage.enabled and s3_protocol.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STORAGE_S3_URL).toBeDefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeDefined(); @@ -443,25 +477,27 @@ describe("legacyStatusValues", () => { it("omits storage S3 fields when storage.enabled is false", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when the storage container id is excluded", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, [CONTAINER_IDS.storage], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); @@ -469,26 +505,28 @@ describe("legacyStatusValues", () => { it("omits storage S3 fields when the storage-api image short name is excluded", () => { // The image repo name (`supabase/storage-api`) differs from the Dockerfile's // build alias (`storage`) — the short name Go matches against is the former. - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, ["storage-api"], NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STORAGE_S3_URL).toBeUndefined(); }); it("omits storage S3 fields when storage.s3_protocol.enabled is false", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, NONE, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.S3_PROTOCOL_ACCESS_KEY_ID).toBeUndefined(); @@ -505,7 +543,7 @@ describe("legacyStatusValues", () => { it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { const config = baseConfig({ api: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -521,7 +559,7 @@ describe("legacyStatusValues", () => { }); it("omits API_URL when SUPABASE_API_ENABLED=false overrides an enabled api.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, @@ -535,7 +573,7 @@ describe("legacyStatusValues", () => { it("includes STUDIO_URL when SUPABASE_STUDIO_ENABLED overrides a disabled studio.enabled", () => { const config = baseConfig({ studio: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -555,7 +593,7 @@ describe("legacyStatusValues", () => { // SUPABASE_AUTH_ENABLED=true from the shell/dotenv, so Auth is up and // status must still print its credentials. const config = baseConfig({ auth: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -572,7 +610,7 @@ describe("legacyStatusValues", () => { }); it("omits the 5 auth fields when SUPABASE_AUTH_ENABLED=false overrides an enabled auth.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, @@ -586,7 +624,7 @@ describe("legacyStatusValues", () => { it("includes MAILPIT_URL when SUPABASE_LOCAL_SMTP_ENABLED overrides a disabled local_smtp.enabled", () => { const config = baseConfig({ local_smtp: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -602,7 +640,7 @@ describe("legacyStatusValues", () => { it("includes storage S3 fields when SUPABASE_STORAGE_ENABLED overrides a disabled storage.enabled", () => { const config = baseConfig({ storage: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -618,7 +656,7 @@ describe("legacyStatusValues", () => { it("includes FUNCTIONS_URL when SUPABASE_EDGE_RUNTIME_ENABLED overrides a disabled edge_runtime.enabled", () => { const config = baseConfig({ edge_runtime: { enabled: false } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -634,7 +672,7 @@ describe("legacyStatusValues", () => { it("includes storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED overrides a disabled s3_protocol.enabled", () => { const config = baseConfig({ storage: { s3_protocol: { enabled: false } } }); - const { values } = legacyStatusValues( + const { values } = runStatusValues( config, CONTAINER_IDS, HOSTNAME, @@ -649,7 +687,7 @@ describe("legacyStatusValues", () => { }); it("omits storage S3 fields when SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false overrides an enabled s3_protocol.enabled", () => { - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, @@ -665,13 +703,14 @@ describe("legacyStatusValues", () => { describe("--override-name remapping", () => { it("remaps a field's output KEY while leaving the value unchanged", () => { const overrides = new Map([["api.url", "NEXT_PUBLIC_SUPABASE_URL"]]); - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR, + {}, ); expect(values.API_URL).toBeUndefined(); expect(values.NEXT_PUBLIC_SUPABASE_URL).toBe("http://127.0.0.1:54321"); @@ -682,13 +721,14 @@ describe("legacyStatusValues", () => { ["api.url", "CUSTOM_API_URL"], ["db.url", "CUSTOM_DB_URL"], ]); - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR, + {}, ); expect(values.CUSTOM_API_URL).toBeDefined(); expect(values.CUSTOM_DB_URL).toBeDefined(); @@ -698,13 +738,14 @@ describe("legacyStatusValues", () => { it("leaves unrelated fields at their default name when only one is overridden", () => { const overrides = new Map([["api.url", "CUSTOM_API_URL"]]); - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR, + {}, ); expect(values.REST_URL).toBeDefined(); }); @@ -715,13 +756,14 @@ describe("legacyStatusValues", () => { ["auth.anon_key", "CUSTOM_ANON_KEY"], ["auth.service_role_key", "CUSTOM_SERVICE_ROLE_KEY"], ]); - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR, + {}, ); expect(values.CUSTOM_JWT_SECRET).toBeDefined(); expect(values.CUSTOM_ANON_KEY).toBeDefined(); @@ -733,13 +775,14 @@ describe("legacyStatusValues", () => { it("remaps the deprecated inbucket.url key independently of mailpit.url", () => { const overrides = new Map([["inbucket.url", "CUSTOM_INBUCKET_URL"]]); - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, NONE, overrides, WORKDIR, + {}, ); expect(values.CUSTOM_INBUCKET_URL).toBeDefined(); expect(values.MAILPIT_URL).toBeDefined(); @@ -752,13 +795,14 @@ describe("legacyStatusValues", () => { // funnel into the same `excluded` array in the handler; the pure function // only sees the merged list. const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; - const { values } = legacyStatusValues( + const { values } = runStatusValues( baseConfig(), CONTAINER_IDS, HOSTNAME, excluded, NO_OVERRIDES, WORKDIR, + {}, ); expect(values.STORAGE_S3_URL).toBeUndefined(); expect(values.STUDIO_URL).toBeUndefined(); diff --git a/apps/cli/src/legacy/shared/legacy-storage-content-type.ts b/apps/cli/src/legacy/shared/legacy-storage-content-type.ts index 52853e3388..af43bdf08b 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-content-type.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-content-type.ts @@ -1,5 +1,3 @@ -import * as nodePath from "node:path"; - import { Effect, FileSystem, Option } from "effect"; import { legacyDetectContentType } from "./legacy-detect-content-type.ts"; @@ -33,7 +31,7 @@ export const legacyReadSniffBytes = Effect.fnUntraced(function* ( }), ).pipe( Effect.map(Option.getOrElse(() => new Uint8Array(0))), - Effect.catch(() => Effect.succeed(new Uint8Array(0))), + Effect.orElseSucceed(() => new Uint8Array(0)), ); }); @@ -45,7 +43,11 @@ export const legacyReadSniffBytes = Effect.fnUntraced(function* ( */ export function legacyRefineUploadContentType(contentType: string, filePath: string): string { if (contentType.includes("text/plain")) { - const ext = nodePath.extname(filePath).toLowerCase(); + const fileName = filePath.slice( + Math.max(filePath.lastIndexOf("/"), filePath.lastIndexOf("\\")) + 1, + ); + const dot = fileName.lastIndexOf("."); + const ext = dot > 0 ? fileName.slice(dot).toLowerCase() : ""; const refined = MIME_BY_EXTENSION[ext]; if (refined !== undefined && refined !== "") return refined; } diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 5f0d74bba5..159ef7b73d 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -1,10 +1,11 @@ import { KONG_LOCAL_CA_CERT } from "@supabase/config"; import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyPlatformApiFactory } from "../auth/legacy-platform-api-factory.service.ts"; import { LegacyCliConfig } from "../config/legacy-cli-config.service.ts"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; import { legacyMapTenantApiKeysError } from "./legacy-get-tenant-api-keys.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; import { legacyExtractServiceKeys } from "./legacy-tenant-keys.ts"; @@ -15,6 +16,17 @@ import { LegacyStorageMissingApiKeyError, } from "./legacy-storage-credentials.errors.ts"; +const legacyStorageCauseText = (value: unknown): string => { + if (value instanceof Error) return value.message; + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; + /** * Resolves the Storage gateway base URL + service-role key (+ local Kong CA), * mirroring `client.NewStorageAPI`. @@ -61,12 +73,13 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts readonly config: LegacyStorageConfigView; }) { const cliConfig = yield* LegacyCliConfig; + const viperEnv = yield* LegacyViperEnv; if (opts.projectRef !== "") { const baseUrl = `https://${opts.projectRef}.${cliConfig.projectHost}`; // Go: `viper.IsSet("AUTH_SERVICE_ROLE_KEY")` → use the env-provided key and // skip the tenant lookup. - const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; + const envKey = Option.getOrUndefined(yield* viperEnv.get("SUPABASE_AUTH_SERVICE_ROLE_KEY")); if (envKey !== undefined && envKey.length > 0) { return { baseUrl, apiKey: envKey, localKongCa: undefined } satisfies LegacyStorageCredentials; } @@ -97,7 +110,7 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const baseUrl = resolveLocalBaseUrl(opts.config); + const baseUrl = yield* resolveLocalBaseUrl(opts.config); const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth); // `status.NewKongClient` installs unconditionally for the local client; its @@ -126,9 +139,9 @@ export const legacyResolveStorageCredentials = Effect.fnUntraced(function* (opts * Local API URL: `legacyResolveApiExternalUrl` with `legacyGetHostname` (Go's * `utils.GetHostname`) supplying the host when `api.external_url` is unset. */ -function resolveLocalBaseUrl(config: LegacyStorageConfigView): string { - return legacyResolveApiExternalUrl(config.api, legacyGetHostname()); -} +const resolveLocalBaseUrl = Effect.fnUntraced(function* (config: LegacyStorageConfigView) { + return legacyResolveApiExternalUrl(config.api, yield* legacyGetHostname); +}); /** * Resolve the service-role key for the local Storage gateway, mirroring Go's @@ -146,7 +159,8 @@ const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: { readonly jwt_secret?: string; readonly service_role_key?: string; }) { - const envSecret = process.env["SUPABASE_AUTH_JWT_SECRET"]; + const viperEnv = yield* LegacyViperEnv; + const envSecret = Option.getOrUndefined(yield* viperEnv.get("SUPABASE_AUTH_JWT_SECRET")); const configuredSecret = envSecret !== undefined && envSecret.length > 0 ? envSecret : auth.jwt_secret; @@ -161,11 +175,11 @@ const resolveLocalServiceRoleKey = Effect.fnUntraced(function* (auth: { jwtSecret = configuredSecret; } - const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; + const envKey = Option.getOrUndefined(yield* viperEnv.get("SUPABASE_AUTH_SERVICE_ROLE_KEY")); const configuredKey = envKey !== undefined && envKey.length > 0 ? envKey : auth.service_role_key; return configuredKey !== undefined && configuredKey.length > 0 ? configuredKey - : generateJwt(jwtSecret, "service_role"); + : yield* Effect.sync(() => generateJwt(jwtSecret, "service_role")); }); /** @@ -207,7 +221,7 @@ const validateLocalKongTls = Effect.fnUntraced(function* ( "PlatformError", (cause) => new LegacyStorageConfigError({ - message: `failed to read TLS cert: ${String(cause.cause ?? cause)}`, + message: `failed to read TLS cert: ${legacyStorageCauseText(cause.cause ?? cause)}`, }), ), ); @@ -217,7 +231,7 @@ const validateLocalKongTls = Effect.fnUntraced(function* ( "PlatformError", (cause) => new LegacyStorageConfigError({ - message: `failed to read TLS key: ${String(cause.cause ?? cause)}`, + message: `failed to read TLS key: ${legacyStorageCauseText(cause.cause ?? cause)}`, }), ), ); @@ -226,35 +240,3 @@ const validateLocalKongTls = Effect.fnUntraced(function* ( return KONG_LOCAL_CA_CERT; }); - -/** - * Builds a `typeof globalThis.fetch` that injects `tls.ca` into every request, - * trusting the provided CA PEM for HTTPS connections to the local Kong gateway. - * Mirrors `newLocalClient`. - * - * Bun's fetch accepts `{ tls: { ca: string } }` via `BunFetchRequestInit`, which - * extends `RequestInit`; no `as` cast is needed. - */ -function legacyKongCaFetch(ca: string): typeof globalThis.fetch { - const fetchImpl = async ( - input: string | URL | Request, - init?: RequestInit, - ): Promise<Response> => { - const caInit: BunFetchRequestInit = { ...init, tls: { ca } }; - return globalThis.fetch(input, caInit); - }; - return Object.assign(fetchImpl, { preconnect: globalThis.fetch.preconnect }); -} - -/** - * The `FetchHttpClient.Fetch` override to provide for Storage gateway calls: a - * CA-trusting fetch for a local https gateway, plain `globalThis.fetch` - * otherwise. Storage calls never use DoH in Go (`newLocalClient` / - * `newRemoteClient` use `status.NewKongClient` / `http.DefaultClient`), so the - * DoH-wrapped shared client is always overridden at the gateway scope. - */ -export function legacyStorageGatewayFetch( - localKongCa: string | undefined, -): typeof globalThis.fetch { - return localKongCa !== undefined ? legacyKongCaFetch(localKongCa) : globalThis.fetch; -} diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.ts index 038dc80b7f..c7677ad31d 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Stream } from "effect"; +import { Effect, FileSystem, Schema, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -9,6 +9,7 @@ import { LegacyStorageGatewayNetworkError, LegacyStorageGatewayStatusError, } from "./legacy-storage-gateway.errors.ts"; +import { legacyErrorMessage } from "./legacy-error-message.ts"; import { legacyGoPathSplit } from "./legacy-storage-url.ts"; /** @@ -25,6 +26,17 @@ import { legacyGoPathSplit } from "./legacy-storage-url.ts"; export const LEGACY_PAGE_LIMIT = 100; export const LEGACY_DELETE_OBJECTS_LIMIT = 1000; +const legacyStorageCauseText = (value: unknown): string => { + if (value instanceof Error) return value.message; + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") return value.toString(); + return Object.prototype.toString.call(value); +}; + interface LegacyBucketSummary { readonly name: string; readonly id: string; @@ -56,7 +68,7 @@ interface LegacyUploadObjectOptions { } export interface LegacyStorageGateway { - readonly listBuckets: () => Effect.Effect< + readonly listBuckets: Effect.Effect< ReadonlyArray<LegacyBucketSummary>, LegacyStorageGatewayNetworkError | LegacyStorageGatewayStatusError >; @@ -103,7 +115,7 @@ export interface LegacyStorageGateway { ReadonlyArray<{ readonly name: string }>, LegacyStorageGatewayNetworkError | LegacyStorageGatewayStatusError >; - readonly listVectorBuckets: () => Effect.Effect< + readonly listVectorBuckets: Effect.Effect< ReadonlyArray<string>, LegacyStorageGatewayNetworkError | LegacyStorageGatewayStatusError >; @@ -113,7 +125,7 @@ export interface LegacyStorageGateway { readonly deleteVectorBucket: ( name: string, ) => Effect.Effect<void, LegacyStorageGatewayNetworkError | LegacyStorageGatewayStatusError>; - readonly listAnalyticsBuckets: () => Effect.Effect< + readonly listAnalyticsBuckets: Effect.Effect< ReadonlyArray<string>, LegacyStorageGatewayNetworkError | LegacyStorageGatewayStatusError >; @@ -174,16 +186,20 @@ function legacyLocalGatewayHint(port: string): string { * connections. */ function isConnectionRefused(error: HttpClientError.TransportError): boolean { - const detail = - `${error.description ?? ""} ${String(error.cause ?? "")} ${error.message}`.toLowerCase(); + const detail = `${error.description ?? ""} ${ + error.cause instanceof Error + ? error.cause.message + : typeof error.cause === "object" && error.cause !== null + ? Object.prototype.toString.call(error.cause) + : legacyStorageCauseText(error.cause) + } ${error.message}`.toLowerCase(); return /econnrefused|connection ?refused|unable to connect/.test(detail); } const parseJsonBody = (body: string): Effect.Effect<unknown, LegacyStorageGatewayNetworkError> => - Effect.try({ - try: () => JSON.parse(body) as unknown, - catch: (cause) => failParse(String(cause)), - }); + Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(body).pipe( + Effect.mapError((cause) => failParse(String(cause))), + ); /** A JSON object → itself; `null` → `{}` (Go zero-value struct); other → `null`. */ function asObject(entry: unknown): Record<string, unknown> | null { @@ -207,7 +223,7 @@ const decodeBucketSummaries = ( const parsed = yield* parseJsonBody(body); if (parsed === null) return []; if (!Array.isArray(parsed)) { - return yield* Effect.fail(failParse("expected an array of buckets")); + return yield* failParse("expected an array of buckets"); } const result: Array<LegacyBucketSummary> = []; for (const entry of parsed) { @@ -215,7 +231,7 @@ const decodeBucketSummaries = ( const name = obj === null ? null : decodeStringField(obj, "name"); const id = obj === null ? null : decodeStringField(obj, "id"); if (name === null || id === null) { - return yield* Effect.fail(failParse("invalid bucket entry")); + return yield* failParse("invalid bucket entry"); } result.push({ name, id }); } @@ -235,21 +251,21 @@ const decodeStorageObjects = ( const parsed = yield* parseJsonBody(body); if (parsed === null) return []; if (!Array.isArray(parsed)) { - return yield* Effect.fail(failParse("expected an array of objects")); + return yield* failParse("expected an array of objects"); } const result: Array<LegacyStorageObject> = []; for (const entry of parsed) { const obj = asObject(entry); if (obj === null) { - return yield* Effect.fail(failParse("invalid object entry")); + return yield* failParse("invalid object entry"); } const name = decodeStringField(obj, "name"); if (name === null) { - return yield* Effect.fail(failParse("invalid object entry")); + return yield* failParse("invalid object entry"); } const idValue = obj["id"]; if (idValue !== undefined && idValue !== null && typeof idValue !== "string") { - return yield* Effect.fail(failParse("invalid object entry")); + return yield* failParse("invalid object entry"); } result.push({ name, isDir: idValue === undefined || idValue === null }); } @@ -263,19 +279,19 @@ const decodeVectorBucketNames = ( const parsed = yield* parseJsonBody(body); const root = asObject(parsed); if (root === null) { - return yield* Effect.fail(failParse("expected a vector bucket list object")); + return yield* failParse("expected a vector bucket list object"); } const list = root["vectorBuckets"]; if (list === undefined || list === null) return []; if (!Array.isArray(list)) { - return yield* Effect.fail(failParse("vectorBuckets must be an array")); + return yield* failParse("vectorBuckets must be an array"); } const names: Array<string> = []; for (const entry of list) { const obj = asObject(entry); const name = obj === null ? null : decodeStringField(obj, "vectorBucketName"); if (name === null) { - return yield* Effect.fail(failParse("invalid vector bucket entry")); + return yield* failParse("invalid vector bucket entry"); } names.push(name); } @@ -298,7 +314,7 @@ const decodeFieldResponse = ( const obj = asObject(parsed); const value = obj === null ? null : decodeStringField(obj, field); if (value === null) { - return yield* Effect.fail(failParse(`invalid ${field} response`)); + return yield* failParse(`invalid ${field} response`); } return value; }); @@ -310,14 +326,14 @@ const decodeDeleteObjects = ( const parsed = yield* parseJsonBody(body); if (parsed === null) return []; if (!Array.isArray(parsed)) { - return yield* Effect.fail(failParse("expected an array of deleted objects")); + return yield* failParse("expected an array of deleted objects"); } const result: Array<{ name: string }> = []; for (const entry of parsed) { const obj = asObject(entry); const name = obj === null ? null : decodeStringField(obj, "name"); if (name === null) { - return yield* Effect.fail(failParse("invalid deleted object entry")); + return yield* failParse("invalid deleted object entry"); } result.push({ name }); } @@ -331,14 +347,14 @@ const decodeAnalyticsBucketNames = ( const parsed = yield* parseJsonBody(body); if (parsed === null) return []; if (!Array.isArray(parsed)) { - return yield* Effect.fail(failParse("expected an array of analytics buckets")); + return yield* failParse("expected an array of analytics buckets"); } const names: Array<string> = []; for (const entry of parsed) { const obj = asObject(entry); const name = obj === null ? null : decodeStringField(obj, "name"); if (name === null) { - return yield* Effect.fail(failParse("invalid analytics bucket entry")); + return yield* failParse("invalid analytics bucket entry"); } names.push(name); } @@ -375,7 +391,7 @@ export const legacyMakeStorageGateway = Effect.fnUntraced(function* (opts: { const hintPort = localGatewayHintPort(opts.baseUrl); const networkError = (cause: unknown): LegacyStorageGatewayNetworkError => { - const base = `failed to execute http request: ${cause}`; + const base = `failed to execute http request: ${legacyErrorMessage(cause)}`; if ( hintPort !== undefined && HttpClientError.isHttpClientError(cause) && @@ -407,13 +423,11 @@ export const legacyMakeStorageGateway = Effect.fnUntraced(function* (opts: { return { status: response.status, body: text }; }).pipe(Effect.mapError(networkError)); if (status !== 200) { - return yield* Effect.fail( - new LegacyStorageGatewayStatusError({ - status, - body, - message: `Error status ${status}: ${body}`, - }), - ); + return yield* new LegacyStorageGatewayStatusError({ + status, + body, + message: `Error status ${status}: ${body}`, + }); } return body; }); @@ -421,10 +435,9 @@ export const legacyMakeStorageGateway = Effect.fnUntraced(function* (opts: { const url = (path: string) => `${opts.baseUrl}${path}`; const gateway: LegacyStorageGateway = { - listBuckets: () => - send(withAuth(HttpClientRequest.get(url("/storage/v1/bucket")))).pipe( - Effect.flatMap(decodeBucketSummaries), - ), + listBuckets: send(withAuth(HttpClientRequest.get(url("/storage/v1/bucket")))).pipe( + Effect.flatMap(decodeBucketSummaries), + ), createBucket: (name, props) => send( withAuth(HttpClientRequest.post(url("/storage/v1/bucket"))).pipe( @@ -513,7 +526,7 @@ export const legacyMakeStorageGateway = Effect.fnUntraced(function* (opts: { Effect.mapError( (cause) => new LegacyStorageGatewayNetworkError({ - message: `failed to execute http request: ${cause}`, + message: `failed to execute http request: ${legacyErrorMessage(cause)}`, }), ), Effect.flatMap(send), @@ -536,12 +549,11 @@ export const legacyMakeStorageGateway = Effect.fnUntraced(function* (opts: { HttpClientRequest.bodyJsonUnsafe({ prefixes }), ), ).pipe(Effect.flatMap(decodeDeleteObjects)), - listVectorBuckets: () => - send( - withAuth(HttpClientRequest.post(url("/storage/v1/vector/ListVectorBuckets"))).pipe( - HttpClientRequest.bodyJsonUnsafe({}), - ), - ).pipe(Effect.flatMap(decodeVectorBucketNames)), + listVectorBuckets: send( + withAuth(HttpClientRequest.post(url("/storage/v1/vector/ListVectorBuckets"))).pipe( + HttpClientRequest.bodyJsonUnsafe({}), + ), + ).pipe(Effect.flatMap(decodeVectorBucketNames)), createVectorBucket: (name) => send( withAuth(HttpClientRequest.post(url("/storage/v1/vector/CreateVectorBucket"))).pipe( @@ -554,10 +566,9 @@ export const legacyMakeStorageGateway = Effect.fnUntraced(function* (opts: { HttpClientRequest.bodyJsonUnsafe({ vectorBucketName: name }), ), ).pipe(Effect.asVoid), - listAnalyticsBuckets: () => - send(withAuth(HttpClientRequest.get(url("/storage/v1/iceberg/bucket")))).pipe( - Effect.flatMap(decodeAnalyticsBucketNames), - ), + listAnalyticsBuckets: send( + withAuth(HttpClientRequest.get(url("/storage/v1/iceberg/bucket"))), + ).pipe(Effect.flatMap(decodeAnalyticsBucketNames)), createAnalyticsBucket: (name) => send( withAuth(HttpClientRequest.post(url("/storage/v1/iceberg/bucket"))).pipe( diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts index 8d9a8e1b80..2a1f388169 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.unit.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer } from "effect"; +import { Effect, Exit, Formatter, Layer, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -65,7 +65,9 @@ function setup(routes: ReadonlyArray<{ match: string; status?: number; body?: un let body: unknown; if (request.body._tag === "Uint8Array") { try { - body = JSON.parse(new TextDecoder().decode(request.body.body)); + body = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown))( + new TextDecoder().decode(request.body.body), + ); } catch { body = undefined; } @@ -131,7 +133,7 @@ describe("legacyMakeStorageGateway", () => { apiKey: "sb_secret_local", userAgent: "ua", }); - yield* opaque.listBuckets(); + yield* opaque.listBuckets; expect(requests[0]?.headers["apikey"]).toBe("sb_secret_local"); expect(requests[0]?.headers["authorization"]).toBeUndefined(); @@ -140,7 +142,7 @@ describe("legacyMakeStorageGateway", () => { apiKey: "ey.jwt.key", userAgent: "ua", }); - yield* jwt.listBuckets(); + yield* jwt.listBuckets; expect(requests[1]?.headers["authorization"]).toBe("Bearer ey.jwt.key"); }).pipe(Effect.provide(layer)); }); @@ -157,7 +159,7 @@ describe("legacyMakeStorageGateway", () => { }); const exit = yield* gateway.moveObject("private", "a", "b").pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - const json = JSON.stringify(exit); + const json = Formatter.formatJson(exit); expect(json).toContain("Error status 404"); // The raw response body is carried on the status error for caller classification. expect(json).toContain("not_found"); diff --git a/apps/cli/src/legacy/shared/legacy-storage-runtime.layer.ts b/apps/cli/src/legacy/shared/legacy-storage-runtime.layer.ts index 4a25c257d9..7323ed5525 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-runtime.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-runtime.layer.ts @@ -10,6 +10,10 @@ import { legacyProjectRefLayer } from "../config/legacy-project-ref.layer.ts"; import { LegacyProjectRefResolver } from "../config/legacy-project-ref.service.ts"; import { legacyDebugLoggerLayer } from "./legacy-debug-logger.layer.ts"; import { LegacyIdentityStitch, legacyIdentityStitchLayer } from "./legacy-identity-stitch.ts"; +import { + LegacyLocalGatewayHttpClient, + legacyLocalGatewayHttpClientLayer, +} from "./legacy-local-gateway-http-client.ts"; import { legacyHttpClientLayer } from "../auth/legacy-http-debug.layer.ts"; import { legacyLinkedProjectCacheLayer } from "../telemetry/legacy-linked-project-cache.layer.ts"; import { LegacyLinkedProjectCache } from "../telemetry/legacy-linked-project-cache.service.ts"; @@ -52,6 +56,7 @@ export function legacyStorageGatewayRuntimeLayer(subcommand: ReadonlyArray<strin cliConfig, platformApiFactory, httpClient, + legacyLocalGatewayHttpClientLayer, legacyProjectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliConfig)), legacyLinkedProjectCacheLayer.pipe( Layer.provide(credentials), @@ -64,7 +69,11 @@ export function legacyStorageGatewayRuntimeLayer(subcommand: ReadonlyArray<strin commandRuntimeLayer([...subcommand]), ); - const _serviceCoverageCheck: Layer.Layer<LegacyStorageGatewayServices, unknown, unknown> = built; + const _serviceCoverageCheck: Layer.Layer< + LegacyStorageGatewayServices, + Layer.Error<typeof built>, + Layer.Services<typeof built> + > = built; void _serviceCoverageCheck; return built; @@ -78,4 +87,5 @@ type LegacyStorageGatewayServices = | LegacyTelemetryState | LegacyIdentityStitch | CommandRuntime - | HttpClient.HttpClient; + | HttpClient.HttpClient + | LegacyLocalGatewayHttpClient; diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 546dd4fa3f..f54579eec1 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -1,3 +1,4 @@ +import { Data } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, @@ -33,11 +34,12 @@ const LEGACY_STORAGE_INVALID_URL_MESSAGE = "URL must match pattern ss:///bucket/ * their own `failed to parse … url: <message>` text, matching Go's * `errors.Errorf("failed to parse … url: %w", err)`. */ -export class LegacyGoUrlParseError extends Error { +export class LegacyGoUrlParseError extends Data.TaggedError("LegacyGoUrlParseError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "LegacyGoUrlParseError"; constructor(rawURL: string, inner: string) { - super(`parse "${rawURL}": ${inner}`); - this.name = "LegacyGoUrlParseError"; + super({ message: `parse "${rawURL}": ${inner}` }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -51,11 +53,12 @@ export class LegacyGoUrlParseError extends Error { * handler can map it to `LegacyStorageUrlPatternError` rather than the * parse-error tagged error. */ -export class LegacyStorageUrlPatternError extends Error { +export class LegacyStorageUrlPatternError extends Data.TaggedError("LegacyStorageUrlPatternError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "LegacyStorageUrlPatternError"; constructor() { - super(LEGACY_STORAGE_INVALID_URL_MESSAGE); - this.name = "LegacyStorageUrlPatternError"; + super({ message: LEGACY_STORAGE_INVALID_URL_MESSAGE }); } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { diff --git a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts index e177ec9d00..00dc936791 100644 --- a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts @@ -1,3 +1,4 @@ +import { Data } from "effect"; import { actionability, type CliErrorActionabilityDeclaration, @@ -38,13 +39,12 @@ const lengthNL = (b: Uint8Array): number => (b.length > 0 && b[b.length - 1] === * - the literal `EOF` when the value contains only blank lines (pflag's * `readAsCSV` propagates `csv.Reader.Read`'s `io.EOF` unchanged). */ -export class LegacyStringSliceFlagParseError extends Error { +export class LegacyStringSliceFlagParseError extends Data.TaggedError( + "LegacyStringSliceFlagParseError", +)<{ readonly message: string; readonly value: string }> { static readonly [ErrorActionabilityFingerprintId] = "LegacyStringSliceFlagParseError"; - readonly value: string; private constructor(value: string, message: string) { - super(message); - this.name = "LegacyStringSliceFlagParseError"; - this.value = value; + super({ message, value }); } /** Mirrors Go `csv.ParseError.Error()`. Line/column are 1-based; column is a byte offset within the physical line. */ static parse( diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts index e861796e10..ece1ef0a11 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.unit.test.ts @@ -1,9 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Effect, Exit, FileSystem, Formatter, Option, Path } from "effect"; import { classifyCliErrorActionability } from "../../shared/telemetry/error-actionability.ts"; import { @@ -56,43 +53,41 @@ describe("legacyTempPaths", () => { describe("legacyReadProjectRefFile", () => { it.effect("returns None when the project-ref file is absent (not linked)", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ref-")); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ref-" }); + const value = yield* readRef(dir); + expect(Option.isNone(value)).toBe(true); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("returns the trimmed ref when the file holds a value", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ref-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "project-ref"), ` ${REF}\n`); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v)).toBe(REF); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ref-" }); + const tempDir = path.join(dir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), ` ${REF}\n`); + const value = yield* readRef(dir); + expect(Option.getOrNull(value)).toBe(REF); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("treats a blank project-ref file as None", () => { - const dir = mkdtempSync(join(tmpdir(), "legacy-ref-")); - mkdirSync(join(dir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(dir, "supabase", ".temp", "project-ref"), " \n"); - return readRef(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.isNone(v)).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ref-" }); + const tempDir = path.join(dir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(tempDir, "project-ref"), " \n"); + const value = yield* readRef(dir); + expect(Option.isNone(value)).toBe(true); + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it.effect("fails with LegacyProjectRefReadError when the ref path is unreadable", () => { @@ -100,22 +95,22 @@ describe("legacyReadProjectRefFile", () => { // read error. Seeding project-ref as a DIRECTORY makes the // read fail with EISDIR (a non-NotFound PlatformError), so it must surface, not // collapse to "unlinked". - const dir = mkdtempSync(join(tmpdir(), "legacy-ref-")); - mkdirSync(join(dir, "supabase", ".temp", "project-ref"), { recursive: true }); - return readRef(dir).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const json = JSON.stringify(exit.cause); - expect(json).toContain("LegacyProjectRefReadError"); - expect(json).toContain("failed to load project ref"); - } - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "legacy-ref-" }); + yield* fs.makeDirectory(path.join(dir, "supabase", ".temp", "project-ref"), { + recursive: true, + }); + const exit = yield* readRef(dir).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = Formatter.formatJson(exit.cause); + expect(json).toContain("LegacyProjectRefReadError"); + expect(json).toContain("failed to load project ref"); + } + yield* fs.remove(dir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)); }); it("classifies an unreadable ref file as permission without an unrelated command", () => { diff --git a/apps/cli/src/legacy/shared/legacy-tenant-versions.ts b/apps/cli/src/legacy/shared/legacy-tenant-versions.ts index 347076c21f..b0e7e339b4 100644 --- a/apps/cli/src/legacy/shared/legacy-tenant-versions.ts +++ b/apps/cli/src/legacy/shared/legacy-tenant-versions.ts @@ -89,7 +89,7 @@ const fetchJson = (request: HttpClientRequest.HttpClientRequest) => const response = yield* httpClient.execute(request); if (response.status !== 200) return Option.none<unknown>(); return Option.some(yield* response.json); - }).pipe(Effect.catch(() => Effect.succeed(Option.none<unknown>()))); + }).pipe(Effect.orElseSucceed(() => Option.none<unknown>())); const fetchText = (request: HttpClientRequest.HttpClientRequest) => Effect.gen(function* () { @@ -97,7 +97,7 @@ const fetchText = (request: HttpClientRequest.HttpClientRequest) => const response = yield* httpClient.execute(request); if (response.status !== 200) return Option.none<string>(); return Option.some(yield* response.text); - }).pipe(Effect.catch(() => Effect.succeed(Option.none<string>()))); + }).pipe(Effect.orElseSucceed(() => Option.none<string>())); export const legacyFetchPostgrestVersion = ( opts: TenantVersionOptions, diff --git a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts index d12270309e..30c835467e 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.command-handler.ts @@ -31,7 +31,7 @@ export const LEGACY_TEST_DB_SHORT = "Tests local database with pgTAP"; const onRunFailure = (error: LegacyTestDbRunError | LegacyTestDbNoTestsError) => Effect.gen(function* () { const output = yield* Output; - if (output.format === "text") return yield* Effect.fail(error); + if (output.format === "text") return yield* error; const processControl = yield* ProcessControl; yield* output.raw(`${error.message}\n`, "stderr"); yield* processControl.setExitCode(1); diff --git a/apps/cli/src/legacy/shared/legacy-test-db.handler.ts b/apps/cli/src/legacy/shared/legacy-test-db.handler.ts index 5663823618..3b2ada6f08 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.handler.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.handler.ts @@ -1,4 +1,3 @@ -import * as nodePath from "node:path"; import { Effect, FileSystem, Option, Path } from "effect"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; @@ -17,6 +16,7 @@ import { } from "../../shared/legacy/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { LegacyViperEnv } from "../../shared/legacy/legacy-viper-env.ts"; import type { LegacyTestDbFlags } from "./legacy-test-db.command-handler.ts"; import { LegacyTestDbEnablePgtapError, @@ -81,6 +81,7 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy const networkIdFlag = yield* LegacyNetworkIdFlag; const dnsResolver = yield* LegacyDnsResolverFlag; const cliArgs = yield* CliArgs; + const legacyEnv = yield* LegacyViperEnv; yield* Effect.gen(function* () { // Reproduce cobra's MarkFlagsMutuallyExclusive("db-url","linked","local") @@ -90,11 +91,9 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy const target = resolveLegacyDbTargetFlags(cliArgs.args); const { setFlags } = target; if (setFlags.length > 1) { - return yield* Effect.fail( - new LegacyTestDbMutuallyExclusiveFlagsError({ - message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, - }), - ); + return yield* new LegacyTestDbMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }); } const connType = target.connType ?? "local"; @@ -103,12 +102,10 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy // discarded on a non-linked target — see push.handler.ts's identical guard // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { - return yield* Effect.fail( - new LegacyTestDbMutuallyExclusiveFlagsError({ - message: - "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", - }), - ); + return yield* new LegacyTestDbMutuallyExclusiveFlagsError({ + message: + "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", + }); } const { conn, isLocal } = yield* resolver.resolve({ @@ -119,6 +116,7 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy }); const args = buildLegacyPgProveArgs({ + path, paths: flags.paths, cwd: runtimeInfo.cwd, workdir: cliConfig.workdir, @@ -154,7 +152,7 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy // "my project" must join the same sanitized network the local stack // created, not the literal raw value. const projectId = sanitizeProjectId( - Option.getOrElse(toml.projectId, () => nodePath.basename(cliConfig.workdir)), + Option.getOrElse(toml.projectId, () => path.basename(cliConfig.workdir)), ); return { _tag: "named" as const, name: `supabase_network_${projectId}` }; }) @@ -209,10 +207,17 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy // `hostConfig.SecurityOpt` when `BITBUCKET_CLONE_DIR` is set // (`apps/cli-go/internal/utils/docker.go:401-405`). Match that exactly: // omit the option in Bitbucket CI, where it would abort container creation. - const inBitbucket = (process.env["BITBUCKET_CLONE_DIR"] ?? "") !== ""; + const inBitbucket = Option.isSome( + yield* legacyEnv + .get("BITBUCKET_CLONE_DIR") + .pipe(Effect.orElseSucceed(() => Option.none<string>())), + ); // Go adds `host.docker.internal:host-gateway` to every container's // ExtraHosts on Linux (`apps/cli-go/internal/utils/docker_linux.go`); macOS/ // Windows Docker Desktop provide the mapping natively (empty there). + const registryOverride = yield* legacyEnv + .get("SUPABASE_INTERNAL_IMAGE_REGISTRY") + .pipe(Effect.orElseSucceed(() => Option.none<string>())); const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; // Stream (rather than inherit) stdout so the verdict can be read on the way @@ -221,7 +226,9 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy // inheriting it did. return yield* docker.runStream( { - image: legacyGetRegistryImageUrl(LEGACY_PG_PROVE_IMAGE), + image: legacyGetRegistryImageUrl(LEGACY_PG_PROVE_IMAGE, { + SUPABASE_INTERNAL_IMAGE_REGISTRY: Option.getOrElse(registryOverride, () => ""), + }), cmd: args.cmd, env: runEnv, binds: args.binds, @@ -260,20 +267,18 @@ export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: Legacy // Non-zero pg_prove exit → fail (exit 1), matching Go's cobra error return. // The TAP failure detail has already streamed to stdout. if (exitCode !== 0) { - return yield* Effect.fail( - new LegacyTestDbRunError({ message: `error running container: exit ${exitCode}` }), - ); + return yield* new LegacyTestDbRunError({ + message: `error running container: exit ${exitCode}`, + }); } // A stream that ends without a trailing newline leaves the verdict unterminated. const finalVerdict = pendingLine.startsWith(VERDICT_PREFIX) ? pendingLine : lastVerdict; const aggregatedFiles = FILES_SUMMARY.exec(lastSummary)?.[1]; if (finalVerdict.trimEnd() === NO_TESTS_VERDICT && aggregatedFiles === "0") { - return yield* Effect.fail( - new LegacyTestDbNoTestsError({ - message: `no pgTAP tests found in ${args.hostPaths.join(", ")}`, - }), - ); + return yield* new LegacyTestDbNoTestsError({ + message: `no pgTAP tests found in ${args.hostPaths.join(", ")}`, + }); } }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts b/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts index 8b466567c4..deef879be6 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts @@ -1,8 +1,6 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Formatter, Layer, Option, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { @@ -17,6 +15,7 @@ import { LegacyNetworkIdFlag, } from "../../shared/legacy/global-flags.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; import { LegacyDbConfigResolver } from "./legacy-db-config.service.ts"; import { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { @@ -78,10 +77,10 @@ function mockDbConnection(opts: { Effect.gen(function* () { execCalls.push(sql); if (opts.enableFails === true && sql.includes("create extension")) { - return yield* Effect.fail(new LegacyDbExecError({ message: "permission denied" })); + return yield* new LegacyDbExecError({ message: "permission denied" }); } if (opts.dropFails === true && sql.includes("drop extension")) { - return yield* Effect.fail(new LegacyDbExecError({ message: "cannot drop" })); + return yield* new LegacyDbExecError({ message: "cannot drop" }); } }), // `test db` never runs a migration batch; keep the seam explicit rather than silent. @@ -211,6 +210,7 @@ interface SetupOpts { dnsResolver?: "native" | "https"; /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ args?: ReadonlyArray<string>; + env?: Readonly<Record<string, string>>; } function setup(opts: SetupOpts = {}) { @@ -234,6 +234,9 @@ function setup(opts: SetupOpts = {}) { ), Layer.succeed(LegacyDnsResolverFlag, opts.dnsResolver ?? "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), BunServices.layer, ); return { layer, out, telemetry, connection, docker, resolver }; @@ -292,21 +295,13 @@ describe("legacy test db integration", () => { it.live("omits --security-opt inside Bitbucket Pipelines (BITBUCKET_CLONE_DIR set)", () => { // Go clears hostConfig.SecurityOpt when BITBUCKET_CLONE_DIR is set, because // Bitbucket rejects --security-opt (apps/cli-go/internal/utils/docker.go:288-293). - const { layer, docker } = setup(); - const prev = process.env["BITBUCKET_CLONE_DIR"]; - process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; + const { layer, docker } = setup({ + env: { BITBUCKET_CLONE_DIR: "/opt/atlassian/pipelines/agent/build" }, + }); return Effect.gen(function* () { yield* legacyTestDb(flags()); expect(docker.lastOpts?.securityOpt).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring( - Effect.sync(() => { - if (prev === undefined) delete process.env["BITBUCKET_CLONE_DIR"]; - else process.env["BITBUCKET_CLONE_DIR"] = prev; - }), - ), - ); + }).pipe(Effect.provide(layer)); }); it.live("skips dropping pgtap when it already existed", () => { @@ -402,7 +397,9 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to enable pgTAP: permission denied"); + expect(Formatter.formatJson(exit.cause)).toContain( + "failed to enable pgTAP: permission denied", + ); } }).pipe(Effect.provide(layer)); }); @@ -413,7 +410,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to connect to postgres"); } }).pipe(Effect.provide(layer)); }); @@ -424,7 +421,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("error running container: exit 3"); + expect(Formatter.formatJson(exit.cause)).toContain("error running container: exit 3"); } }).pipe(Effect.provide(layer)); }); @@ -438,7 +435,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags({ paths: ["tests/db"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "no pgTAP tests found in /work/project/tests/db", ); } @@ -456,7 +453,7 @@ describe("legacy test db integration", () => { it.live("detects the NOTESTS verdict arriving one byte per chunk", () => { const { layer } = setup({ exitCode: 0, - stdout: [..."Files=0, Tests=0\nResult: NOTESTS\n"], + stdout: Array.from("Files=0, Tests=0\nResult: NOTESTS\n"), }); return Effect.gen(function* () { const exit = yield* Effect.exit(legacyTestDb(flags())); @@ -544,7 +541,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("failed to run docker"); + expect(Formatter.formatJson(exit.cause)).toContain("failed to run docker"); } }).pipe(Effect.provide(layer)); }); @@ -573,7 +570,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); } @@ -587,7 +584,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags())); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); } @@ -599,11 +596,9 @@ describe("legacy test db integration", () => { // `--linked=false` is still "explicitly set" → linked branch. // The resolver mock will be called with connType="linked". const { layer } = setup({ args: ["--linked=false"] }); - return Effect.gen(function* () { - // The resolver mock doesn't validate — success means routing reached resolver.resolve - // with connType "linked" (no mutual-exclusion error, no local fallback error). - yield* legacyTestDb(flags()); - }).pipe(Effect.provide(layer)); + // The resolver mock doesn't validate — success means routing reached resolver.resolve + // with connType "linked" (no mutual-exclusion error, no local fallback error). + return legacyTestDb(flags()).pipe(Effect.provide(layer)); }); it.live("tests the project given via --project-ref --linked", () => { @@ -627,7 +622,7 @@ describe("legacy test db integration", () => { const exit = yield* Effect.exit(legacyTestDb(flags({ projectRef: Option.some(FLAG_REF) }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( + expect(Formatter.formatJson(exit.cause)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); } @@ -683,13 +678,19 @@ describe("legacy test db integration", () => { const tempWorkdir = useLegacyTempWorkdir(); it.live("sanitizes a configured project_id when naming the local network (Go parity)", () => { const workdir = tempWorkdir.current; - mkdirSync(join(workdir, "supabase"), { recursive: true }); // Go auto-fixes an invalid project_id via sanitizeProjectId (config.go:471, // 803-805); the local stack network is created from the sanitized id, so // `test db --local` must join `supabase_network_My_Project`, not the raw value. - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "My Project"\n'); const { layer, docker } = setup({ workdir }); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const supabaseDir = path.join(workdir, "supabase"); + yield* fs.makeDirectory(supabaseDir, { recursive: true }); + yield* fs.writeFileString( + path.join(supabaseDir, "config.toml"), + 'project_id = "My Project"\n', + ); yield* legacyTestDb(flags()); expect(docker.lastOpts?.network).toEqual({ _tag: "named", diff --git a/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts b/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts index 928806e5cc..cdb05e2f9e 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.layers.unit.test.ts @@ -48,6 +48,7 @@ import { LegacyDbConnection } from "./legacy-db-connection.service.ts"; import { LegacyIdentityStitch } from "./legacy-identity-stitch.ts"; import { legacyTestDbRuntimeLayer } from "./legacy-test-db.layers.ts"; +import { makeLegacyViperEnvLayer } from "../../shared/legacy/legacy-viper-env.ts"; const tempRoot = useLegacyTempWorkdir("supabase-test-db-layers-"); @@ -97,6 +98,7 @@ function ambientStubs() { mockLegacyCliConfig({ workdir: "/tmp/test-db-layers-test" }), mockLegacyTelemetryStateLayer, heavyServiceStubs, + makeLegacyViperEnvLayer(), ); } @@ -108,8 +110,9 @@ describe("legacyTestDbRuntimeLayer — LegacyIdentityStitch exposure", () => { const stitch = yield* Effect.serviceOption(LegacyIdentityStitch); expect(Option.isSome(stitch)).toBe(true); }).pipe( - Effect.provide(legacyTestDbRuntimeLayer(["test", "db"])), - Effect.provide(ambientStubs()), + Effect.provide( + legacyTestDbRuntimeLayer(["test", "db"]).pipe(Layer.provideMerge(ambientStubs())), + ), ); }, ); diff --git a/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.ts b/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.ts index c7b856f0e2..7efbb6b80f 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.ts @@ -1,5 +1,4 @@ -import * as nodePath from "node:path"; -import { Option } from "effect"; +import { Option, type Path } from "effect"; import { legacyToDockerPath } from "./legacy-docker-path.ts"; @@ -35,13 +34,14 @@ export interface LegacyPgProveArgs { * Output is unchanged — the full file path is still passed to `pg_prove`. */ export function buildLegacyPgProveArgs(opts: { + readonly path: Path.Path; readonly paths: ReadonlyArray<string>; readonly cwd: string; readonly workdir: string; readonly debug: boolean; }): LegacyPgProveArgs { const testFiles = - opts.paths.length > 0 ? opts.paths : [nodePath.resolve(opts.workdir, "supabase", "tests")]; + opts.paths.length > 0 ? opts.paths : [opts.path.resolve(opts.workdir, "supabase", "tests")]; const cmd: string[] = ["pg_prove", "--ext", ".pg", "--ext", ".sql", "-r"]; const binds: string[] = []; @@ -52,7 +52,7 @@ export function buildLegacyPgProveArgs(opts: { let workingDir = ""; for (const candidate of testFiles) { - const fp = nodePath.isAbsolute(candidate) ? candidate : nodePath.join(opts.cwd, candidate); + const fp = opts.path.isAbsolute(candidate) ? candidate : opts.path.join(opts.cwd, candidate); const dockerPath = legacyToDockerPath(fp); cmd.push(dockerPath); hostPaths.push(fp); @@ -62,8 +62,8 @@ export function buildLegacyPgProveArgs(opts: { // own directory, and a single-file bind leaves siblings absent in the // container (CLI-1139). Directories are mounted as-is. The file-vs-directory // heuristic (presence of an extension) matches Go's workingDir logic. - const isFile = nodePath.posix.extname(dockerPath) !== ""; - const hostMount = isFile ? nodePath.dirname(fp) : fp; + const isFile = opts.path.extname(dockerPath) !== ""; + const hostMount = isFile ? opts.path.dirname(fp) : fp; const dockerMount = legacyToDockerPath(hostMount); // Dedupe by container target: two files in the same directory (or a file plus diff --git a/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.unit.test.ts b/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.unit.test.ts index 3d4e069e48..5a2a1bc602 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.pg-prove-args.unit.test.ts @@ -1,106 +1,133 @@ -import { describe, expect, test } from "vitest"; -import { Option } from "effect"; +import { BunPath } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Option, Path } from "effect"; import { buildLegacyPgProveArgs } from "./legacy-test-db.pg-prove-args.ts"; +const withPath = <A>(f: (path: Path.Path) => A) => + Effect.gen(function* () { + return f(yield* Path.Path); + }).pipe(Effect.provide(BunPath.layer)); + describe("buildLegacyPgProveArgs", () => { - test("defaults to <workdir>/supabase/tests when no paths are given", () => { - const result = buildLegacyPgProveArgs({ - paths: [], - cwd: "/cwd", - workdir: "/work", - debug: false, - }); - expect(result.cmd).toEqual([ - "pg_prove", - "--ext", - ".pg", - "--ext", - ".sql", - "-r", - "/work/supabase/tests", - ]); - expect(result.binds).toEqual(["/work/supabase/tests:/work/supabase/tests:ro"]); - expect(Option.getOrNull(result.workingDir)).toBe("/work/supabase/tests"); - }); + it.effect("defaults to <workdir>/supabase/tests when no paths are given", () => + withPath((path) => { + const result = buildLegacyPgProveArgs({ + path, + paths: [], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.cmd).toEqual([ + "pg_prove", + "--ext", + ".pg", + "--ext", + ".sql", + "-r", + "/work/supabase/tests", + ]); + expect(result.binds).toEqual(["/work/supabase/tests:/work/supabase/tests:ro"]); + expect(Option.getOrNull(result.workingDir)).toBe("/work/supabase/tests"); + }), + ); - test("resolves relative paths against cwd and mounts them read-only", () => { - const result = buildLegacyPgProveArgs({ - paths: ["nested"], - cwd: "/cwd", - workdir: "/work", - debug: false, - }); - expect(result.binds).toEqual(["/cwd/nested:/cwd/nested:ro"]); - expect(Option.getOrNull(result.workingDir)).toBe("/cwd/nested"); - }); + it.effect("resolves relative paths against cwd and mounts them read-only", () => + withPath((path) => { + const result = buildLegacyPgProveArgs({ + path, + paths: ["nested"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.binds).toEqual(["/cwd/nested:/cwd/nested:ro"]); + expect(Option.getOrNull(result.workingDir)).toBe("/cwd/nested"); + }), + ); - test("mounts the containing directory (not the lone file) for a single file path", () => { - // CLI-1139: mounting only the file leaves sibling `\ir` includes absent in - // the container. Mount the parent directory so they resolve; the file path is - // still what pg_prove runs. - const result = buildLegacyPgProveArgs({ - paths: ["/abs/dir/a_test.sql"], - cwd: "/cwd", - workdir: "/work", - debug: false, - }); - expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); - expect(result.cmd).toContain("/abs/dir/a_test.sql"); - expect(Option.getOrNull(result.workingDir)).toBe("/abs/dir"); - }); + it.effect("mounts the containing directory (not the lone file) for a single file path", () => + withPath((path) => { + // CLI-1139: mounting only the file leaves sibling `\ir` includes absent in + // the container. Mount the parent directory so they resolve; the file path is + // still what pg_prove runs. + const result = buildLegacyPgProveArgs({ + path, + paths: ["/abs/dir/a_test.sql"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); + expect(result.cmd).toContain("/abs/dir/a_test.sql"); + expect(Option.getOrNull(result.workingDir)).toBe("/abs/dir"); + }), + ); - test("dedupes the bind when multiple files share a directory", () => { - const result = buildLegacyPgProveArgs({ - paths: ["/abs/dir/a_test.sql", "/abs/dir/b_test.sql"], - cwd: "/cwd", - workdir: "/work", - debug: false, - }); - // A single bind for the shared directory; both files still run. - expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); - expect(result.cmd).toContain("/abs/dir/a_test.sql"); - expect(result.cmd).toContain("/abs/dir/b_test.sql"); - }); + it.effect("dedupes the bind when multiple files share a directory", () => + withPath((path) => { + const result = buildLegacyPgProveArgs({ + path, + paths: ["/abs/dir/a_test.sql", "/abs/dir/b_test.sql"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + // A single bind for the shared directory; both files still run. + expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); + expect(result.cmd).toContain("/abs/dir/a_test.sql"); + expect(result.cmd).toContain("/abs/dir/b_test.sql"); + }), + ); - test("dedupes a file's mount against its explicitly-given containing directory", () => { - const result = buildLegacyPgProveArgs({ - paths: ["/abs/dir", "/abs/dir/a_test.sql"], - cwd: "/cwd", - workdir: "/work", - debug: false, - }); - expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); - // workingDir is derived from the first path (a directory → itself). - expect(Option.getOrNull(result.workingDir)).toBe("/abs/dir"); - }); + it.effect("dedupes a file's mount against its explicitly-given containing directory", () => + withPath((path) => { + const result = buildLegacyPgProveArgs({ + path, + paths: ["/abs/dir", "/abs/dir/a_test.sql"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); + // workingDir is derived from the first path (a directory → itself). + expect(Option.getOrNull(result.workingDir)).toBe("/abs/dir"); + }), + ); - test("keeps the first path's workingDir when multiple paths are given", () => { - const result = buildLegacyPgProveArgs({ - paths: ["/abs/first_test.sql", "/abs/second/dir"], - cwd: "/cwd", - workdir: "/work", - debug: false, - }); - expect(result.binds).toEqual([ - // First path is a file → its containing directory is mounted. - "/abs:/abs:ro", - // Second path is a directory → mounted as-is. - "/abs/second/dir:/abs/second/dir:ro", - ]); - // workingDir is derived from the first path only (a file → its parent). - expect(Option.getOrNull(result.workingDir)).toBe("/abs"); - // `hostPaths` reports what pg_prove searches — the files/dirs, not their mounts. - expect(result.hostPaths).toEqual(["/abs/first_test.sql", "/abs/second/dir"]); - }); + it.effect("keeps the first path's workingDir when multiple paths are given", () => + withPath((path) => { + const result = buildLegacyPgProveArgs({ + path, + paths: ["/abs/first_test.sql", "/abs/second/dir"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.binds).toEqual([ + // First path is a file → its containing directory is mounted. + "/abs:/abs:ro", + // Second path is a directory → mounted as-is. + "/abs/second/dir:/abs/second/dir:ro", + ]); + // workingDir is derived from the first path only (a file → its parent). + expect(Option.getOrNull(result.workingDir)).toBe("/abs"); + // `hostPaths` reports what pg_prove searches — the files/dirs, not their mounts. + expect(result.hostPaths).toEqual(["/abs/first_test.sql", "/abs/second/dir"]); + }), + ); - test("appends --verbose when debug is enabled", () => { - const result = buildLegacyPgProveArgs({ - paths: [], - cwd: "/cwd", - workdir: "/work", - debug: true, - }); - expect(result.cmd.at(-1)).toBe("--verbose"); - }); + it.effect("appends --verbose when debug is enabled", () => + withPath((path) => { + const result = buildLegacyPgProveArgs({ + path, + paths: [], + cwd: "/cwd", + workdir: "/work", + debug: true, + }); + expect(result.cmd.at(-1)).toBe("--verbose"); + }), + ); }); diff --git a/apps/cli/src/legacy/shared/legacy-timestamp.format.ts b/apps/cli/src/legacy/shared/legacy-timestamp.format.ts index 1d164e02d5..ad25b6def5 100644 --- a/apps/cli/src/legacy/shared/legacy-timestamp.format.ts +++ b/apps/cli/src/legacy/shared/legacy-timestamp.format.ts @@ -1,3 +1,5 @@ +import { DateTime, Option } from "effect"; + function pad2(value: number): string { return value.toString().padStart(2, "0"); } @@ -16,11 +18,14 @@ export function formatLegacyTimestamp(value: string): string { if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { return value; } - const parsed = Date.parse(value); - if (Number.isNaN(parsed)) return value; - const date = new Date(parsed); - return ( - `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ` + - `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}` + return DateTime.make(value).pipe( + Option.map((date) => { + const parts = DateTime.toPartsUtc(date); + return ( + `${parts.year}-${pad2(parts.month)}-${pad2(parts.day)} ` + + `${pad2(parts.hour)}:${pad2(parts.minute)}:${pad2(parts.second)}` + ); + }), + Option.getOrElse(() => value), ); } diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-notice.ts b/apps/cli/src/legacy/shared/legacy-upgrade-notice.ts index 7086511a82..5aece5e87e 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-notice.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-notice.ts @@ -5,12 +5,13 @@ * Go's own offline backoff. */ -import { lstat, mkdir, open, readFile } from "node:fs/promises"; -import { constants as fsConstants, existsSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { Effect } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Config, Data, Duration, Effect, FileSystem, Option, Path, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { FetchHttpClient } from "effect/unstable/http"; import { hasRootHelpOrVersionFlag, @@ -22,6 +23,11 @@ import { CLI_VERSION } from "../../shared/cli/version.ts"; import { legacyBold, legacyYellow } from "./legacy-colors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { legacyCandidateDotenvFilenames } from "./legacy-project-environment.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LATEST_RELEASE_URL = "https://api.github.com/repos/supabase/cli/releases/latest"; const UPGRADE_GUIDE_URL = @@ -30,6 +36,15 @@ const CACHE_TTL_MS = 10 * 60 * 60 * 1000; /** No Go equivalent (its client sets no timeout); bounds this pre-exit hook's latency. */ const FETCH_TIMEOUT_MS = 3000; +export class LegacyUpgradeNoticeError extends Data.TaggedError("LegacyUpgradeNoticeError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + /** Go `strconv.ParseBool`'s true spellings — anything else, including garbage, leaves the notifier on. */ const PARSE_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); @@ -150,46 +165,51 @@ export function legacyFormatUpgradeNotice(latestTag: string, currentVersion: str } /** - * Writes the cache file refusing to follow a symlink at the FINAL path - * component. The `lstat` guard at the call site runs before a network fetch - * bounded only by `FETCH_TIMEOUT_MS`, so by write time it only proves the path - * was safe seconds ago; a concurrent process that swaps `cli-latest` for a - * symlink inside that window makes a plain `writeFile` truncate an arbitrary - * user-writable target (CWE-59/TOCTOU). `O_NOFOLLOW` moves that one decision - * into the kernel's `open`, which fails with `ELOOP` instead. - * - * This does NOT close the window for the `supabase/` and `.temp/` DIRECTORY - * components: `O_NOFOLLOW` only applies to the last component, and resolving - * the rest against a verified directory handle needs `openat`, which Node does - * not expose. A directory swapped for a symlink inside the same window is still - * followed by the preceding `mkdir -p` and by this `open`. Those components - * keep only the advisory `lstat`/`isRealDirOrAbsent` checks — narrower than the - * final-component guarantee, and still stricter than Go, which writes through - * symlinks at every level. - * - * Same path, mode, and truncate semantics as the `writeFile` it replaces, so - * Go's filesystem side effects are unchanged — including the empty-string - * offline backoff write. - * - * `O_NOFOLLOW` is POSIX-only; Node leaves it undefined on Windows, where this - * falls back to the plain flags and the final component drops back to the same - * advisory-only footing as the directories. Creating a symlink there needs - * Developer Mode or `SeCreateSymbolicLinkPrivilege`. + * Write the offline cache without following a symlink at the final path component. + * The pre-fetch and post-fetch link checks are advisory; this leaf adapter repeats + * the invariant in the kernel after the network operation has completed. Node's + * numeric open flags are used only at this foreign platform boundary; all callers + * remain Effect-native and receive the operation's failure through the Effect error + * channel. */ -async function writeCacheFileNoFollow(cacheFile: string, contents: string): Promise<void> { - const handle = await open( - cacheFile, - fsConstants.O_WRONLY | - fsConstants.O_CREAT | - fsConstants.O_TRUNC | - (fsConstants.O_NOFOLLOW ?? 0), - 0o644, +function writeCacheFileNoFollow( + cacheFile: string, + contents: string, +): Effect.Effect<void, LegacyUpgradeNoticeError> { + return Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + import("node:fs").then(({ constants: fsConstants }) => + import("node:fs/promises").then(({ open: openFile }) => { + const flags = + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_TRUNC | + (fsConstants.O_NOFOLLOW ?? 0); + return openFile(cacheFile, flags, 0o644); + }), + ), + catch: (cause) => + new LegacyUpgradeNoticeError({ + message: `failed to open cache file: ${errorMessage(cause)}`, + cause, + }), + }), + (handle) => + Effect.tryPromise({ + try: () => handle.writeFile(contents), + catch: (cause) => + new LegacyUpgradeNoticeError({ + message: `failed to write cache file: ${errorMessage(cause)}`, + cause, + }), + }), + (handle) => + Effect.tryPromise({ + try: () => handle.close(), + catch: () => undefined, + }).pipe(Effect.ignore), ); - try { - await handle.writeFile(contents); - } finally { - await handle.close(); - } } /** @@ -203,22 +223,29 @@ function resolveNoticeBaseDir( cwd: string, args: ReadonlyArray<string>, env: Readonly<Record<string, string | undefined>>, + fs: FileSystem.FileSystem, + path: Path.Path, isValueTakingFlagToken?: (token: string) => boolean, -): string { +): Effect.Effect<string> { // Viper: a set flag beats the env even when empty, and an empty effective // value falls through to the ancestor walk (`ChangeWorkDir`'s own rule). const flagValue = lastGlobalFlagValue(args, "--workdir", isValueTakingFlagToken); const explicit = flagValue !== undefined ? flagValue : env["SUPABASE_WORKDIR"]; if (explicit !== undefined && explicit !== "") { - return resolve(cwd, explicit); - } - let current = cwd; - while (true) { - if (existsSync(join(current, "supabase", "config.toml"))) return current; - const parent = dirname(current); - if (parent === current) return cwd; - current = parent; + return Effect.succeed(path.resolve(cwd, explicit)); } + return Effect.gen(function* () { + let current = cwd; + while (true) { + const exists = yield* fs + .exists(path.join(current, "supabase", "config.toml")) + .pipe(Effect.orElseSucceed(() => false)); + if (exists) return current; + const parent = path.dirname(current); + if (parent === current) return cwd; + current = parent; + } + }); } /** @@ -233,38 +260,50 @@ function resolveNoticeBaseDir( * `<base>/supabase` then `<base>`, first file to define a key wins, and the * shell env always beats a chain value (godotenv never overrides). */ -async function projectDotenvValues( +function projectDotenvValues( base: string, env: Readonly<Record<string, string | undefined>>, -): Promise<Record<string, string>> { - const merged: Record<string, string> = {}; - // Go's walk loads `<base>/supabase` then `<base>` — except at the filesystem - // root, where `loadNestedEnv`'s `cwd != filepath.Dir(repoDir)` bound - // degenerates (`Dir("/") == "/"`) and only `/supabase` is read. - const dirs = dirname(base) === base ? [join(base, "supabase")] : [join(base, "supabase"), base]; - for (const dir of dirs) { - for (const filename of legacyCandidateDotenvFilenames(env["SUPABASE_ENV"] || "development")) { - const contents = await readFile(join(dir, filename), "utf8").catch(() => undefined); - if (contents === undefined) continue; - try { - for (const [key, value] of Object.entries(parseDotEnv(contents))) { + fs: FileSystem.FileSystem, + path: Path.Path, +): Effect.Effect<Record<string, string>> { + return Effect.gen(function* () { + const merged: Record<string, string> = {}; + // Go's walk loads `<base>/supabase` then `<base>` — except at the filesystem + // root, where `loadNestedEnv`'s `cwd != filepath.Dir(repoDir)` bound + // degenerates (`Dir("/") == "/"`) and only `/supabase` is read. + const dirs = + path.dirname(base) === base + ? [path.join(base, "supabase")] + : [path.join(base, "supabase"), base]; + for (const dir of dirs) { + for (const filename of legacyCandidateDotenvFilenames(env["SUPABASE_ENV"] || "development")) { + const contents = yield* fs.readFileString(path.join(dir, filename)).pipe(Effect.option); + if (Option.isNone(contents)) continue; + const parsed = yield* Effect.try({ + try: () => parseDotEnv(contents.value), + catch: (cause) => new LegacyUpgradeNoticeError({ message: "invalid dotenv", cause }), + }).pipe(Effect.option); + if (Option.isNone(parsed)) { + // A malformed file is only reachable here when the command never + // loaded config (a load would have failed the run before this hook), + // and then Go never read any of the chain either. + return {}; + } + for (const [key, value] of Object.entries(parsed.value)) { if (!(key in merged)) merged[key] = value; } - } catch { - // A malformed file is only reachable here when the command never - // loaded config (a load would have failed the run before this hook), - // and then Go never read any of the chain either. - return {}; } } - } - return merged; + return merged; + }); } /** Absent (we may create it) or a real directory — never a symlink to follow. */ -async function isRealDirOrAbsent(path: string): Promise<boolean> { - const stats = await lstat(path).catch(() => undefined); - return stats === undefined || stats.isDirectory(); +function isRealDirOrAbsent(path: string, fs: FileSystem.FileSystem): Effect.Effect<boolean> { + return fs.readLink(path).pipe( + Effect.as(false), + Effect.orElseSucceed(() => true), + ); } export interface LegacyUpgradeNoticeDeps { @@ -278,11 +317,13 @@ export interface LegacyUpgradeNoticeDeps { readonly resolvedCwd?: string; readonly currentVersion: string; readonly now: () => number; - readonly fetchLatestTag: () => Promise<string>; + readonly fetchLatestTag: Effect.Effect<string, LegacyUpgradeNoticeError>; readonly writeStderr: (text: string) => void; } -export async function legacyRunUpgradeNotice(deps: LegacyUpgradeNoticeDeps): Promise<void> { +export const legacyRunUpgradeNotice = Effect.fnUntraced(function* (deps: LegacyUpgradeNoticeDeps) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; if (legacyUpdateNotifierDisabled(deps.env["SUPABASE_NO_UPDATE_NOTIFIER"])) return; // `--help`/`--version` and a bare group's clean ShowHelp all skip cobra's @@ -295,102 +336,173 @@ export async function legacyRunUpgradeNotice(deps: LegacyUpgradeNoticeDeps): Pro const base = builtin ? deps.cwd : (deps.resolvedCwd ?? - resolveNoticeBaseDir(deps.cwd, deps.args, deps.env, deps.isValueTakingFlagToken)); - const projectEnv = builtin ? {} : await projectDotenvValues(base, deps.env); + (yield* resolveNoticeBaseDir( + deps.cwd, + deps.args, + deps.env, + fs, + path, + deps.isValueTakingFlagToken, + ))); + const projectEnv = builtin ? {} : yield* projectDotenvValues(base, deps.env, fs, path); // godotenv never overrides: a shell env that defines a key at all beats the // project dotenv chain, even when set to an empty or unparseable value. const effectiveEnv = (key: string): string | undefined => deps.env[key] !== undefined ? deps.env[key] : projectEnv[key]; if (legacyUpdateNotifierDisabled(effectiveEnv("SUPABASE_NO_UPDATE_NOTIFIER"))) return; const debug = debugEnabled(deps, builtin, effectiveEnv("SUPABASE_DEBUG")); - const supabaseDir = join(base, "supabase"); - const tempDir = join(supabaseDir, ".temp"); - const cacheFile = join(tempDir, "cli-latest"); + const supabaseDir = path.join(base, "supabase"); + const tempDir = path.join(supabaseDir, ".temp"); + const cacheFile = path.join(tempDir, "cli-latest"); // A hostile checkout can commit a symlink at any level of this well-known // path to clobber an arbitrary user-writable file (CWE-59): a symlink // anywhere disables the cache. Do not relax to `stat`/`existsSync`, which // follow links. These checks are advisory only — they run before a fetch that - // can take FETCH_TIMEOUT_MS, so they cannot be trusted at write time. The - // write re-establishes the guarantee in the kernel for the cache file itself - // via `O_NOFOLLOW`; the two directory components stay advisory-only for want - // of `openat` (see `writeCacheFileNoFollow`). - const cacheLstat = await lstat(cacheFile).catch(() => undefined); + // can take FETCH_TIMEOUT_MS, so the cache file is checked again immediately + // before writing. + const cacheSymlink = yield* fs.readLink(cacheFile).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + const cacheLstat = cacheSymlink + ? Option.none<FileSystem.File.Info>() + : yield* fs.stat(cacheFile).pipe(Effect.option); const cachePathIsSafe = - cacheLstat?.isSymbolicLink() !== true && - (await isRealDirOrAbsent(supabaseDir)) && - (await isRealDirOrAbsent(tempDir)); + !cacheSymlink && + (yield* isRealDirOrAbsent(supabaseDir, fs)) && + (yield* isRealDirOrAbsent(tempDir, fs)); // Go's `rootCmd.Flag("version").Changed` — a subcommand's own `--version` must not bypass the cache. const forceFetch = hasRootVersionFlag(deps.args, deps.isValueTakingFlagToken); const cacheFresh = cachePathIsSafe && - cacheLstat !== undefined && - deps.now() <= cacheLstat.mtime.getTime() + CACHE_TTL_MS; + Option.isSome(cacheLstat) && + Option.isSome(cacheLstat.value.mtime) && + deps.now() <= cacheLstat.value.mtime.value.getTime() + CACHE_TTL_MS; let latestTag: string; if (forceFetch || !cacheFresh) { - let notifyError: Error | undefined; - latestTag = await deps.fetchLatestTag().catch((error: unknown) => { + let notifyError: LegacyUpgradeNoticeError | undefined; + const fetched = yield* deps.fetchLatestTag.pipe( + Effect.match({ + onFailure: (error) => ({ ok: false as const, error }), + onSuccess: (tag) => ({ ok: true as const, tag }), + }), + ); + if (fetched.ok) { + latestTag = fetched.tag; + } else { // Go's `GetLatestRelease` wrap (`internal/utils/release.go:42`) — // capital F and all. - notifyError = new Error(`Failed to fetch latest release: ${errorMessage(error)}`); - return ""; - }); + notifyError = new LegacyUpgradeNoticeError({ + message: `Failed to fetch latest release: ${errorMessage(fetched.error)}`, + cause: fetched.error, + }); + latestTag = ""; + } // Go's `checkUpgrade` (`cmd/root.go:254-258`) overwrites the fetch error // with the offline-backoff write's result when inside a project, so a // successful write silences the diagnostic — only a missing project (no // backoff) or a failing write leaves an error to log, carrying the write // path's own wraps (`failed to mkdir`/`failed to write file`, misc.go). - if (cachePathIsSafe && existsSync(supabaseDir)) { - notifyError = await mkdir(tempDir, { recursive: true, mode: 0o755 }).then( - () => - writeCacheFileNoFollow(cacheFile, latestTag).then( - () => undefined, - (error: unknown) => new Error(`failed to write file: ${errorMessage(error)}`), - ), - (error: unknown) => new Error(`failed to mkdir: ${errorMessage(error)}`), + const supabaseExists = yield* fs.exists(supabaseDir).pipe(Effect.orElseSucceed(() => false)); + if (cachePathIsSafe && supabaseExists) { + const mkdirError = yield* fs.makeDirectory(tempDir, { recursive: true, mode: 0o755 }).pipe( + Effect.match({ + onFailure: (error) => + new LegacyUpgradeNoticeError({ + message: `failed to mkdir: ${errorMessage(error)}`, + cause: error, + }), + onSuccess: () => undefined, + }), ); + if (mkdirError !== undefined) { + notifyError = mkdirError; + } else { + const symlinkAfterFetch = yield* fs.readLink(cacheFile).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!symlinkAfterFetch) { + notifyError = yield* writeCacheFileNoFollow(cacheFile, latestTag).pipe( + Effect.match({ + onFailure: (error) => + new LegacyUpgradeNoticeError({ + message: `failed to write file: ${errorMessage(error)}`, + cause: error, + }), + onSuccess: () => undefined, + }), + ); + } + } } if (notifyError !== undefined && debug) { deps.writeStderr(`${stripVTControlCharacters(notifyError.message)}\n`); } } else { - latestTag = await readFile(cacheFile, "utf8").catch((error: unknown) => { - if (debug) { - deps.writeStderr( - `failed to read cli version: ${stripVTControlCharacters(errorMessage(error))}\n`, - ); - } - return ""; - }); + latestTag = yield* fs.readFileString(cacheFile).pipe( + Effect.match({ + onFailure: (error) => { + if (debug) { + deps.writeStderr( + `failed to read cli version: ${stripVTControlCharacters(errorMessage(error))}\n`, + ); + } + return ""; + }, + onSuccess: (value) => value, + }), + ); } // Gated on the anchored semver match: no escape bytes can reach the terminal. if (legacyIsNewerCliVersion(latestTag, deps.currentVersion)) { deps.writeStderr(`${legacyFormatUpgradeNotice(latestTag, deps.currentVersion)}\n`); } -} +}); -async function fetchLatestReleaseTag(): Promise<string> { +const LatestReleaseSchema = Schema.Struct({ tag_name: Schema.optional(Schema.String) }); +const decodeLatestRelease = Schema.decodeUnknownEffect(LatestReleaseSchema); + +function fetchLatestReleaseTag( + token: string | undefined, +): Effect.Effect<string, LegacyUpgradeNoticeError, HttpClient.HttpClient> { // Go's `GetGitHubClient` authenticates when GITHUB_TOKEN is set, for the // higher rate limit on shared-egress CI runners. - const token = process.env["GITHUB_TOKEN"]; - const response = await fetch(LATEST_RELEASE_URL, { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - headers: { - accept: "application/vnd.github+json", - "user-agent": `SupabaseCLI/${CLI_VERSION}`, - ...(token !== undefined && token !== "" ? { authorization: `Bearer ${token}` } : {}), - }, - }); - if (!response.ok) { - throw new Error(`unexpected status ${response.status}`); + let request = HttpClientRequest.get(LATEST_RELEASE_URL).pipe( + HttpClientRequest.setHeader("accept", "application/vnd.github+json"), + HttpClientRequest.setHeader("user-agent", `SupabaseCLI/${CLI_VERSION}`), + ); + if (token !== undefined && token !== "") { + request = request.pipe(HttpClientRequest.setHeader("authorization", `Bearer ${token}`)); } - const body: unknown = await response.json(); - const tag = - typeof body === "object" && body !== null && "tag_name" in body ? body.tag_name : undefined; - return typeof tag === "string" ? tag : ""; + return Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + if (response.status < 200 || response.status >= 300) { + return yield* new LegacyUpgradeNoticeError({ + message: `unexpected status ${response.status}`, + }); + } + const body = yield* response.json; + const decoded = yield* decodeLatestRelease(body).pipe( + Effect.orElseSucceed(() => ({ tag_name: undefined })), + ); + return decoded.tag_name ?? ""; + }).pipe( + Effect.timeout(Duration.millis(FETCH_TIMEOUT_MS)), + Effect.mapError((cause) => + cause instanceof LegacyUpgradeNoticeError + ? cause + : new LegacyUpgradeNoticeError({ + message: `failed to fetch latest release: ${errorMessage(cause)}`, + cause, + }), + ), + ); } /** The `runCli` post-success hook. A rejected `Effect.promise` is a defect, so `Effect.ignoreCause` (not `ignore`) keeps this unable to fail. */ @@ -403,11 +515,24 @@ export const legacyUpgradeNoticeHook = ( readonly isValueTakingFlagToken: (token: string) => boolean; }, ): Effect.Effect<void> => - info.delegatedToGo + (info.delegatedToGo ? Effect.void - : Effect.promise(() => - legacyRunUpgradeNotice({ - env: process.env, + : Effect.gen(function* () { + const configured = yield* Effect.all({ + noUpdateNotifier: Config.option(Config.string("SUPABASE_NO_UPDATE_NOTIFIER")), + workdir: Config.option(Config.string("SUPABASE_WORKDIR")), + environment: Config.option(Config.string("SUPABASE_ENV")), + debug: Config.option(Config.string("SUPABASE_DEBUG")), + githubToken: Config.option(Config.string("GITHUB_TOKEN")), + }); + const env: Readonly<Record<string, string | undefined>> = { + SUPABASE_NO_UPDATE_NOTIFIER: Option.getOrUndefined(configured.noUpdateNotifier), + SUPABASE_WORKDIR: Option.getOrUndefined(configured.workdir), + SUPABASE_ENV: Option.getOrUndefined(configured.environment), + SUPABASE_DEBUG: Option.getOrUndefined(configured.debug), + }; + yield* legacyRunUpgradeNotice({ + env, args, cleanShowHelp: info.cleanShowHelp, isValueTakingFlagToken: info.isValueTakingFlagToken, @@ -415,9 +540,12 @@ export const legacyUpgradeNoticeHook = ( resolvedCwd: info.workingDirectory, currentVersion: CLI_VERSION, now: Date.now, - fetchLatestTag: fetchLatestReleaseTag, + fetchLatestTag: fetchLatestReleaseTag(Option.getOrUndefined(configured.githubToken)).pipe( + Effect.provide(FetchHttpClient.layer), + ), writeStderr: (text) => { process.stderr.write(text); }, - }), - ).pipe(Effect.ignoreCause); + }); + }).pipe(Effect.ignoreCause) + ).pipe(Effect.provide(BunServices.layer)); diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-notice.unit.test.ts b/apps/cli/src/legacy/shared/legacy-upgrade-notice.unit.test.ts index 9468e7e01f..2ea809cc58 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-notice.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-notice.unit.test.ts @@ -1,25 +1,14 @@ -import { - chmodSync, - lstatSync, - mkdirSync, - readdirSync, - mkdtempSync, - readFileSync, - rmSync, - statSync, - symlinkSync, - utimesSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { describe, expect, it } from "vitest"; -import { Effect } from "effect"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Option, Path } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { type LegacyUpgradeNoticeDeps, + LegacyUpgradeNoticeError, legacyFormatUpgradeNotice, legacyIsNewerCliVersion, legacyRunUpgradeNotice, @@ -83,7 +72,68 @@ describe("legacyFormatUpgradeNotice", () => { }); describe("legacyRunUpgradeNotice", () => { + const tempRoot = useLegacyTempWorkdir("supabase-legacy-upgrade-notice-"); + const pathService = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + const join = (...segments: ReadonlyArray<string>): string => pathService.join(...segments); + const runNotice = ( + ctx: ReturnType<typeof setup>, + deps: LegacyUpgradeNoticeDeps = ctx.deps, + ): Effect.Effect< + void, + PlatformError.PlatformError | LegacyUpgradeNoticeError, + FileSystem.FileSystem | Path.Path + > => ctx.fixtures.pipe(Effect.andThen(legacyRunUpgradeNotice(deps))); + const readText = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file); + }); + const readTextOption = (file: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(file).pipe(Effect.option); + }); + const makeDirectory = (directory: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(directory, { recursive: true }); + }); + + const fixtureFs = ( + workdir: string, + opts: { + readonly project?: boolean; + readonly cacheContent?: string; + readonly cacheAgeMs?: number; + }, + ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(workdir, { recursive: true }); + if (opts.project !== false) { + yield* fs.makeDirectory(join(workdir, "supabase"), { recursive: true }); + yield* fs.writeFileString( + join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n', + ); + } + if (opts.cacheContent !== undefined) { + const tempDir = join(workdir, "supabase", ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + const cacheFile = join(tempDir, "cli-latest"); + const existing = yield* fs.readFileString(cacheFile).pipe(Effect.option); + if (Option.isNone(existing)) { + yield* fs.writeFileString(cacheFile, opts.cacheContent); + if (opts.cacheAgeMs !== undefined) { + const then = DateTime.toDate(DateTime.makeUnsafe(0 - opts.cacheAgeMs)); + yield* fs.utimes(cacheFile, then, then); + } + } + } + }); + let workdir: string; + let contextIndex = 0; function setup(opts: { readonly env?: Record<string, string>; @@ -95,20 +145,7 @@ describe("legacyRunUpgradeNotice", () => { readonly cacheAgeMs?: number; readonly currentVersion?: string; }) { - workdir = mkdtempSync(join(tmpdir(), "supabase-legacy-upgrade-notice-")); - if (opts.project !== false) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); - } - if (opts.cacheContent !== undefined) { - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - const cacheFile = join(workdir, "supabase", ".temp", "cli-latest"); - writeFileSync(cacheFile, opts.cacheContent); - if (opts.cacheAgeMs !== undefined) { - const then = new Date(Date.now() - opts.cacheAgeMs); - utimesSync(cacheFile, then, then); - } - } + workdir = join(tempRoot.current, `case-${contextIndex++}`); let fetchCalls = 0; const stderr: Array<string> = []; const deps: LegacyUpgradeNoticeDeps = { @@ -116,13 +153,13 @@ describe("legacyRunUpgradeNotice", () => { args: opts.args ?? ["db", "start"], cwd: workdir, currentVersion: opts.currentVersion ?? "2.113.0", - now: Date.now, - fetchLatestTag: () => { + now: () => 0, + fetchLatestTag: Effect.suspend(() => { fetchCalls += 1; return opts.fetchFails === true - ? Promise.reject(new Error("offline")) - : Promise.resolve(opts.latestTag ?? "v2.114.0"); - }, + ? Effect.fail(new LegacyUpgradeNoticeError({ message: "offline" })) + : Effect.succeed(opts.latestTag ?? "v2.114.0"); + }), writeStderr: (text) => { stderr.push(text); }, @@ -136,503 +173,621 @@ describe("legacyRunUpgradeNotice", () => { return stripVTControlCharacters(stderr.join("")); }, cachePath: join(workdir, "supabase", ".temp", "cli-latest"), - cleanup: () => rmSync(workdir, { recursive: true, force: true }), + fixtures: fixtureFs(workdir, opts), }; } - it("prints the notice and caches the tag when a newer release exists", async () => { - const ctx = setup({}); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect("prints the notice and caches the tag when a newer release exists", () => + Effect.gen(function* () { + const ctx = setup({}); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(1); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); + expect(yield* readText(ctx.cachePath)).toBe("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("SUPABASE_NO_UPDATE_NOTIFIER=1 skips the fetch and the notice entirely", async () => { - const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" } }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); + it.effect("SUPABASE_NO_UPDATE_NOTIFIER=1 skips the fetch and the notice entirely", () => + Effect.gen(function* () { + const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "1" } }); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("stays silent when already on the latest release", async () => { - const ctx = setup({ latestTag: "v2.113.0" }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); + it.effect("stays silent when already on the latest release", () => + Effect.gen(function* () { + const ctx = setup({ latestTag: "v2.113.0" }); + yield* runNotice(ctx); + expect(ctx.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("reads a fresh cache instead of fetching", async () => { - const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + it.effect("reads a fresh cache instead of fetching", () => + Effect.gen(function* () { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("keeps a cache fresh at the exact ten-hour boundary", async () => { - const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - await legacyRunUpgradeNotice({ - ...ctx.deps, - now: () => statSync(ctx.cachePath).mtime.getTime() + 10 * 60 * 60 * 1000, - }); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + it.effect("keeps a cache fresh at the exact ten-hour boundary", () => + Effect.gen(function* () { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(ctx.cachePath); + const now = Option.isSome(info.mtime) ? info.mtime.value.getTime() + 10 * 60 * 60 * 1000 : 0; + yield* runNotice(ctx, { ...ctx.deps, now: () => now }); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("refetches when the cache is older than ten hours", async () => { - const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect("refetches when the cache is older than ten hours", () => + Effect.gen(function* () { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(1); + expect(yield* readText(ctx.cachePath)).toBe("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("--version forces a fetch through a fresh cache", async () => { - const ctx = setup({ args: ["--version"], cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - ctx.cleanup(); - }); + it.effect("--version forces a fetch through a fresh cache", () => + Effect.gen(function* () { + const ctx = setup({ args: ["--version"], cacheContent: "v2.115.0", cacheAgeMs: 60_000 }); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(1); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", async () => { - const ctx = setup({ fetchFails: true, project: false, env: { SUPABASE_DEBUG: "1" } }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - ctx.cleanup(); - }); + it.effect( + "SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", + () => + Effect.gen(function* () { + const ctx = setup({ fetchFails: true, project: false, env: { SUPABASE_DEBUG: "1" } }); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("--debug=true surfaces fetch failures and --debug=false silences them, like pflag", async () => { - const on = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug=true"] }); - await legacyRunUpgradeNotice(on.deps); - expect(on.stderr).toContain("Failed to fetch latest release"); - on.cleanup(); - - // A set flag (`--debug=false`) beats SUPABASE_DEBUG, like viper. - const off = setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--debug=false"], - env: { SUPABASE_DEBUG: "1" }, - }); - await legacyRunUpgradeNotice(off.deps); - expect(off.stderr).toBe(""); - off.cleanup(); - }); + it.effect( + "--debug=true surfaces fetch failures and --debug=false silences them, like pflag", + () => + Effect.gen(function* () { + const on = setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--debug=true"], + }); + yield* runNotice(on); + expect(on.stderr).toContain("Failed to fetch latest release"); + + // A set flag (`--debug=false`) beats SUPABASE_DEBUG, like viper. + const off = setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--debug=false"], + env: { SUPABASE_DEBUG: "1" }, + }); + yield* runNotice(off); + expect(off.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", async () => { - // AutomaticEnv binds inside cobra.OnInitialize, which --help/--version - // never reach; BindPFlags runs at package init, so the flag still reads. - const viaEnv = setup({ - fetchFails: true, - project: false, - env: { SUPABASE_DEBUG: "1" }, - args: ["--version"], - }); - await legacyRunUpgradeNotice(viaEnv.deps); - expect(viaEnv.stderr).toBe(""); - viaEnv.cleanup(); - - const viaFlag = setup({ fetchFails: true, project: false, args: ["--version", "--debug"] }); - await legacyRunUpgradeNotice(viaFlag.deps); - expect(viaFlag.stderr).toContain("Failed to fetch latest release: offline"); - viaFlag.cleanup(); - }); + it.effect( + "a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", + () => + Effect.gen(function* () { + // AutomaticEnv binds inside cobra.OnInitialize, which --help/--version + // never reach; BindPFlags runs at package init, so the flag still reads. + const viaEnv = setup({ + fetchFails: true, + project: false, + env: { SUPABASE_DEBUG: "1" }, + args: ["--version"], + }); + yield* runNotice(viaEnv); + expect(viaEnv.stderr).toBe(""); + + const viaFlag = setup({ fetchFails: true, project: false, args: ["--version", "--debug"] }); + yield* runNotice(viaFlag); + expect(viaFlag.stderr).toContain("Failed to fetch latest release: offline"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("a failed fetch stays silent and writes an empty cache to back off", async () => { - const ctx = setup({ fetchFails: true }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toBe(""); - expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); - ctx.cleanup(); - }); + it.effect("a failed fetch stays silent and writes an empty cache to back off", () => + Effect.gen(function* () { + const ctx = setup({ fetchFails: true }); + yield* runNotice(ctx); + expect(ctx.stderr).toBe(""); + expect(yield* readText(ctx.cachePath)).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("a failed fetch surfaces its error under --debug when there is no project to back off in", async () => { - const ctx = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug"] }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - expect(ctx.stderr).not.toContain("A new version of Supabase CLI is available"); - ctx.cleanup(); - }); + it.effect( + "a failed fetch surfaces its error under --debug when there is no project to back off in", + () => + Effect.gen(function* () { + const ctx = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug"] }); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + expect(ctx.stderr).not.toContain("A new version of Supabase CLI is available"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", async () => { - const afterTerminator = setup({ - fetchFails: true, - project: false, - args: ["db", "start", "--", "--debug"], - }); - await legacyRunUpgradeNotice(afterTerminator.deps); - expect(afterTerminator.stderr).toBe(""); - afterTerminator.cleanup(); - - const consumedValue = setup({ - fetchFails: true, - project: false, - args: ["--profile", "--debug", "db", "start"], - }); - await legacyRunUpgradeNotice(consumedValue.deps); - expect(consumedValue.stderr).toBe(""); - consumedValue.cleanup(); - }); + it.effect( + "a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", + () => + Effect.gen(function* () { + const afterTerminator = setup({ + fetchFails: true, + project: false, + args: ["db", "start", "--", "--debug"], + }); + yield* runNotice(afterTerminator); + expect(afterTerminator.stderr).toBe(""); + + const consumedValue = setup({ + fetchFails: true, + project: false, + args: ["--profile", "--debug", "db", "start"], + }); + yield* runNotice(consumedValue); + expect(consumedValue.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.skipIf(process.getuid?.() === 0)( + it.effect.skipIf(process.getuid?.() === 0)( "a cache write failure reports the stable cli-latest path", - async () => { - const ctx = setup({ args: ["db", "start", "--debug"] }); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - chmodSync(join(workdir, "supabase", ".temp"), 0o555); - await legacyRunUpgradeNotice(ctx.deps); - chmodSync(join(workdir, "supabase", ".temp"), 0o755); - expect(ctx.stderr).toContain("failed to write file"); - expect(ctx.stderr).toContain("cli-latest"); - ctx.cleanup(); - }, + () => + Effect.gen(function* () { + const ctx = setup({ args: ["db", "start", "--debug"] }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + const tempDir = join(workdir, "supabase", ".temp"); + yield* makeDirectory(tempDir); + yield* fs.chmod(tempDir, 0o555); + yield* runNotice(ctx); + yield* fs.chmod(tempDir, 0o755); + expect(ctx.stderr).toContain("failed to write file"); + expect(ctx.stderr).toContain("cli-latest"); + }).pipe(Effect.provide(BunServices.layer)), ); - it.skipIf(process.platform === "win32")( + it.effect.skipIf(process.platform === "win32")( "creates the cache directory and file with Go-compatible modes", - async () => { - const ctx = setup({}); - const previousUmask = process.umask(0); - try { - await legacyRunUpgradeNotice(ctx.deps); - } finally { - process.umask(previousUmask); - } - expect(statSync(join(workdir, "supabase", ".temp")).mode & 0o777).toBe(0o755); - expect(statSync(ctx.cachePath).mode & 0o777).toBe(0o644); - ctx.cleanup(); - }, + () => + Effect.gen(function* () { + const ctx = setup({}); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + const previousUmask = process.umask(0); + yield* runNotice(ctx).pipe( + Effect.ensuring(Effect.sync(() => process.umask(previousUmask))), + ); + const tempInfo = yield* fs.stat(join(workdir, "supabase", ".temp")); + const cacheInfo = yield* fs.stat(ctx.cachePath); + expect(tempInfo.mode & 0o777).toBe(0o755); + expect(cacheInfo.mode & 0o777).toBe(0o644); + }).pipe(Effect.provide(BunServices.layer)), ); - it.skipIf(process.platform === "win32")( + it.effect.skipIf(process.platform === "win32")( "updates a writable cache without requiring write access to its directory", - async () => { - const ctx = setup({ - cacheContent: "v2.115.0", - cacheAgeMs: 11 * 60 * 60 * 1000, - }); - const tempDir = join(workdir, "supabase", ".temp"); - chmodSync(tempDir, 0o555); - try { - await legacyRunUpgradeNotice(ctx.deps); - } finally { - chmodSync(tempDir, 0o755); - } - expect(ctx.stderr).not.toContain("failed to write file"); - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }, + () => + Effect.gen(function* () { + const ctx = setup({ cacheContent: "v2.115.0", cacheAgeMs: 11 * 60 * 60 * 1000 }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + const tempDir = join(workdir, "supabase", ".temp"); + yield* fs.chmod(tempDir, 0o555); + yield* runNotice(ctx).pipe(Effect.ensuring(fs.chmod(tempDir, 0o755).pipe(Effect.ignore))); + expect(ctx.stderr).not.toContain("failed to write file"); + expect(yield* readText(ctx.cachePath)).toBe("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), ); - it("a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", async () => { - // `login --name --debug`: pflag hands `--debug` to `--name`. The real CLI - // passes the resolved leaf's value-flag predicate into the hook. - const ctx = setup({ fetchFails: true, project: false, args: ["login", "--name", "--debug"] }); - await legacyRunUpgradeNotice({ - ...ctx.deps, - isValueTakingFlagToken: (token) => token === "--name", - }); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); - - it("a false root version flag before a leaf runs the normal path, workdir included", async () => { - // `--version=false <leaf>`: cobra parses false, runs the leaf with - // `ChangeWorkDir` — but pflag still marks the flag changed, forcing the - // fetch. Only the built-in classification must not trigger. - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - mkdirSync(join(flagged, "supabase"), { recursive: true }); - await legacyRunUpgradeNotice({ - ...ctx.deps, - args: ["--workdir", flagged, "--version=false", "db", "push"], - }); - expect(readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect( + "a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", + () => + Effect.gen(function* () { + // `login --name --debug`: pflag hands `--debug` to `--name`. The real CLI + // passes the resolved leaf's value-flag predicate into the hook. + const ctx = setup({ + fetchFails: true, + project: false, + args: ["login", "--name", "--debug"], + }); + yield* runNotice(ctx, { + ...ctx.deps, + isValueTakingFlagToken: (token) => token === "--name", + }); + expect(ctx.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.skipIf(process.getuid?.() === 0)( - "an existing read-only cache file fails the backoff write, like Go's direct open", - async () => { - const ctx = setup({ - args: ["db", "start", "--debug"], - cacheContent: "v2.115.0", - cacheAgeMs: 11 * 60 * 60 * 1000, + it.effect("a false root version flag before a leaf runs the normal path, workdir included", () => + Effect.gen(function* () { + // `--version=false <leaf>`: cobra parses false, runs the leaf with + // `ChangeWorkDir` — but pflag still marks the flag changed, forcing the + // fetch. Only the built-in classification must not trigger. + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + yield* makeDirectory(join(flagged, "supabase")); + yield* runNotice(ctx, { + ...ctx.deps, + args: ["--workdir", flagged, "--version=false", "db", "push"], }); - chmodSync(ctx.cachePath, 0o444); - await legacyRunUpgradeNotice(ctx.deps); - chmodSync(ctx.cachePath, 0o644); - expect(ctx.stderr).toContain("failed to write file"); - // The stale cache survives, exactly like Go's failed open. - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.115.0"); - ctx.cleanup(); - }, + expect(yield* readText(join(flagged, "supabase", ".temp", "cli-latest"))).toBe("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), ); - it("project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", async () => { - // A symlinked .temp disables the backoff write, so the fetch error is what - // remains to log — and the debug gate resolves through the project chain. - const ctx = setup({ fetchFails: true }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); - mkdirSync(join(workdir, "elsewhere"), { recursive: true }); - symlinkSync(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("Failed to fetch latest release"); - ctx.cleanup(); - - // A shell env that defines the key blocks the chain, like os.Environ. - const blocked = setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); - mkdirSync(join(workdir, "elsewhere"), { recursive: true }); - symlinkSync(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); - await legacyRunUpgradeNotice(blocked.deps); - expect(blocked.stderr).toBe(""); - blocked.cleanup(); - }); - - it("a failed fetch inside a project stays silent under --debug, matching Go's backoff", async () => { - const ctx = setup({ fetchFails: true, args: ["db", "start", "--debug"] }); - await legacyRunUpgradeNotice(ctx.deps); - // The empty-cache backoff write succeeds, so Go emits no debug line. - expect(ctx.stderr).toBe(""); - expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); - ctx.cleanup(); - }); - - it("outside a project the notice still prints but nothing is cached", async () => { - const ctx = setup({ project: false }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("v2.114.0"); - expect(() => readFileSync(ctx.cachePath, "utf8")).toThrow(); - ctx.cleanup(); - }); + it.effect.skipIf(process.getuid?.() === 0)( + "an existing read-only cache file fails the backoff write, like Go's direct open", + () => + Effect.gen(function* () { + const ctx = setup({ + args: ["db", "start", "--debug"], + cacheContent: "v2.115.0", + cacheAgeMs: 11 * 60 * 60 * 1000, + }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + yield* fs.chmod(ctx.cachePath, 0o444); + yield* runNotice(ctx); + yield* fs.chmod(ctx.cachePath, 0o644); + expect(ctx.stderr).toContain("failed to write file"); + // The stale cache survives, exactly like Go's failed open. + expect(yield* readText(ctx.cachePath)).toBe("v2.115.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", async () => { - const ctx = setup({ project: false }); - const flagDir = join(workdir, "flag-project"); - const envDir = join(workdir, "env-project"); - for (const dir of [flagDir, envDir]) { - mkdirSync(join(dir, "supabase"), { recursive: true }); - } - const flagCtx = { ...ctx.deps, args: ["db", "start", "--workdir", flagDir] }; - await legacyRunUpgradeNotice(flagCtx); - expect(readFileSync(join(flagDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - - const lastWinsCtx = { - ...ctx.deps, - args: ["db", "start", "--workdir", join(workdir, "ignored"), `--workdir=${flagDir}`], - }; - await legacyRunUpgradeNotice(lastWinsCtx); - expect(readFileSync(join(flagDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); + it.effect( + "project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", + () => + Effect.gen(function* () { + // A symlinked .temp disables the backoff write, so the fetch error is what + // remains to log — and the debug gate resolves through the project chain. + const ctx = setup({ fetchFails: true }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); + yield* makeDirectory(join(workdir, "elsewhere")); + yield* fs.symlink(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("Failed to fetch latest release"); + + // A shell env that defines the key blocks the chain, like os.Environ. + const blocked = setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); + yield* blocked.fixtures; + yield* fs.writeFileString(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); + yield* makeDirectory(join(workdir, "elsewhere")); + yield* fs.symlink(join(workdir, "elsewhere"), join(workdir, "supabase", ".temp")); + yield* runNotice(blocked); + expect(blocked.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - const envCtx = { ...ctx.deps, env: { SUPABASE_WORKDIR: envDir } }; - await legacyRunUpgradeNotice(envCtx); - expect(readFileSync(join(envDir, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect( + "a failed fetch inside a project stays silent under --debug, matching Go's backoff", + () => + Effect.gen(function* () { + const ctx = setup({ fetchFails: true, args: ["db", "start", "--debug"] }); + yield* runNotice(ctx); + // The empty-cache backoff write succeeds, so Go emits no debug line. + expect(ctx.stderr).toBe(""); + expect(yield* readText(ctx.cachePath)).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("uses the successful command's resolved working directory without re-resolving flags", async () => { - const ctx = setup({ project: false, args: ["bootstrap", "--workdir", "prompted"] }); - const prompted = join(workdir, "prompted"); - mkdirSync(join(prompted, "supabase"), { recursive: true }); + it.effect("outside a project the notice still prints but nothing is cached", () => + Effect.gen(function* () { + const ctx = setup({ project: false }); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("v2.114.0"); + expect(Option.isNone(yield* readTextOption(ctx.cachePath))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - await legacyRunUpgradeNotice({ ...ctx.deps, resolvedCwd: prompted }); + it.effect( + "resolves the cache under --workdir, then SUPABASE_WORKDIR, ahead of the cwd walk", + () => + Effect.gen(function* () { + const ctx = setup({ project: false }); + const flagDir = join(workdir, "flag-project"); + const envDir = join(workdir, "env-project"); + for (const dir of [flagDir, envDir]) { + yield* makeDirectory(join(dir, "supabase")); + } + const flagCtx = { ...ctx.deps, args: ["db", "start", "--workdir", flagDir] }; + yield* runNotice(ctx, flagCtx); + expect(yield* readText(join(flagDir, "supabase", ".temp", "cli-latest"))).toBe("v2.114.0"); + + const lastWinsCtx = { + ...ctx.deps, + args: ["db", "start", "--workdir", join(workdir, "ignored"), `--workdir=${flagDir}`], + }; + yield* runNotice(ctx, lastWinsCtx); + expect(yield* readText(join(flagDir, "supabase", ".temp", "cli-latest"))).toBe("v2.114.0"); + + const envCtx = { ...ctx.deps, env: { SUPABASE_WORKDIR: envDir } }; + yield* runNotice(ctx, envCtx); + expect(yield* readText(join(envDir, "supabase", ".temp", "cli-latest"))).toBe("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - expect(readFileSync(join(prompted, "supabase", ".temp", "cli-latest"), "utf8")).toBe( - "v2.114.0", - ); - expect(() => readFileSync(ctx.cachePath, "utf8")).toThrow(); - ctx.cleanup(); - }); + it.effect( + "uses the successful command's resolved working directory without re-resolving flags", + () => + Effect.gen(function* () { + const ctx = setup({ project: false, args: ["bootstrap", "--workdir", "prompted"] }); + const prompted = join(workdir, "prompted"); + yield* makeDirectory(join(prompted, "supabase")); - it("an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", async () => { - const ctx = setup({}); - const envDir = join(workdir, "env-project"); - mkdirSync(join(envDir, "supabase"), { recursive: true }); - await legacyRunUpgradeNotice({ - ...ctx.deps, - args: ["db", "start", "--workdir="], - env: { SUPABASE_WORKDIR: envDir }, - }); - // The set-but-empty flag suppresses the env, so the walk finds the real project. - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - expect(() => readFileSync(join(envDir, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); - ctx.cleanup(); - }); + yield* runNotice(ctx, { ...ctx.deps, resolvedCwd: prompted }); - it("a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", async () => { - const ctx = setup({}); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - }); + expect(yield* readText(join(prompted, "supabase", ".temp", "cli-latest"))).toBe("v2.114.0"); + expect(Option.isNone(yield* readTextOption(ctx.cachePath))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("a shell env that defines the key beats the project dotenv, like godotenv's no-override", async () => { - // Defined-but-unparseable in the shell env: Go's os.Environ presence stops - // godotenv from overriding, and ParseBool("") keeps the notifier on. - const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); - ctx.cleanup(); - }); + it.effect( + "an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", + () => + Effect.gen(function* () { + const ctx = setup({}); + const envDir = join(workdir, "env-project"); + yield* makeDirectory(join(envDir, "supabase")); + yield* runNotice(ctx, { + ...ctx.deps, + args: ["db", "start", "--workdir="], + env: { SUPABASE_WORKDIR: envDir }, + }); + // The set-but-empty flag suppresses the env, so the walk finds the real project. + expect(yield* readText(ctx.cachePath)).toBe("v2.114.0"); + expect( + Option.isNone(yield* readTextOption(join(envDir, "supabase", ".temp", "cli-latest"))), + ).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("--help ignores the project dotenv opt-out — Go never loads config for the built-ins", async () => { - const ctx = setup({ args: ["--help"] }); - writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); - ctx.cleanup(); - }); + it.effect( + "a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", + () => + Effect.gen(function* () { + const ctx = setup({}); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + join(workdir, "supabase", ".env"), + "SUPABASE_NO_UPDATE_NOTIFIER=1\n", + ); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toBe(""); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", async () => { - // Go serves the built-ins without `ChangeWorkDir`, so the flagged project - // must not gain a cache entry; the caller's cwd (no supabase/) writes none. - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - mkdirSync(join(flagged, "supabase"), { recursive: true }); - for (const args of [ - ["--workdir", flagged, "--help"], - ["--workdir", flagged, "--version"], - // Valued spellings request the same built-ins, and a false value still - // does: the non-runnable root/group serves help before `preRun`. - ["--workdir", flagged, "--help=true"], - ["branches", "--workdir", flagged, "-h=1"], - ["--workdir", flagged, "--version=true"], - ["branches", "--workdir", flagged, "--help=true", "--help=false"], - // The space-form operand never stops the root version built-in. - ["--workdir", flagged, "--version", "true"], - ]) { - await legacyRunUpgradeNotice({ ...ctx.deps, args }); - } - expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); - // A subcommand's own value-taking --version still resolves the project. - await legacyRunUpgradeNotice({ - ...ctx.deps, - args: ["db", "reset", "--workdir", flagged, "--version", "20240101000000"], - }); - expect(readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toBe("v2.114.0"); - ctx.cleanup(); - }); + it.effect( + "a shell env that defines the key beats the project dotenv, like godotenv's no-override", + () => + Effect.gen(function* () { + // Defined-but-unparseable in the shell env: Go's os.Environ presence stops + // godotenv from overriding, and ParseBool("") keeps the notifier on. + const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + join(workdir, "supabase", ".env"), + "SUPABASE_NO_UPDATE_NOTIFIER=1\n", + ); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("does not treat Effect's -v shorthand as Go's root version flag", async () => { - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - const cacheFile = join(flagged, "supabase", ".temp", "cli-latest"); - mkdirSync(join(flagged, "supabase", ".temp"), { recursive: true }); - writeFileSync(cacheFile, "v2.115.0"); + it.effect( + "--help ignores the project dotenv opt-out — Go never loads config for the built-ins", + () => + Effect.gen(function* () { + const ctx = setup({ args: ["--help"] }); + yield* ctx.fixtures; + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + join(workdir, "supabase", ".env"), + "SUPABASE_NO_UPDATE_NOTIFIER=1\n", + ); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available"); + }).pipe(Effect.provide(BunServices.layer)), + ); - await legacyRunUpgradeNotice({ - ...ctx.deps, - args: ["--workdir", flagged, "-v"], - }); + it.effect("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", () => + Effect.gen(function* () { + // Go serves the built-ins without `ChangeWorkDir`, so the flagged project + // must not gain a cache entry; the caller's cwd (no supabase/) writes none. + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + yield* makeDirectory(join(flagged, "supabase")); + for (const args of [ + ["--workdir", flagged, "--help"], + ["--workdir", flagged, "--version"], + ["--workdir", flagged, "--help=true"], + ["branches", "--workdir", flagged, "-h=1"], + ["--workdir", flagged, "--version=true"], + ["branches", "--workdir", flagged, "--help=true", "--help=false"], + ["--workdir", flagged, "--version", "true"], + ]) { + yield* runNotice(ctx, { ...ctx.deps, args }); + } + expect( + Option.isNone(yield* readTextOption(join(flagged, "supabase", ".temp", "cli-latest"))), + ).toBe(true); + // A subcommand's own value-taking --version still resolves the project. + yield* runNotice(ctx, { + ...ctx.deps, + args: ["db", "reset", "--workdir", flagged, "--version", "20240101000000"], + }); + expect(yield* readText(join(flagged, "supabase", ".temp", "cli-latest"))).toBe("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - expect(ctx.fetchCalls).toBe(0); - expect(ctx.stderr).toContain("v2.115.0"); - ctx.cleanup(); - }); + it.effect("does not treat Effect's -v shorthand as Go's root version flag", () => + Effect.gen(function* () { + const ctx = setup({ project: false }); + const fs = yield* FileSystem.FileSystem; + const flagged = join(workdir, "flagged"); + const cacheFile = join(flagged, "supabase", ".temp", "cli-latest"); + yield* makeDirectory(join(flagged, "supabase", ".temp")); + yield* fs.writeFileString(cacheFile, "v2.115.0"); + yield* runNotice(ctx, { ...ctx.deps, args: ["--workdir", flagged, "-v"] }); + expect(ctx.fetchCalls).toBe(0); + expect(ctx.stderr).toContain("v2.115.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("a clean group-help exit resolves against the bare cwd, ignoring --workdir", async () => { - const ctx = setup({ project: false }); - const flagged = join(workdir, "flagged"); - mkdirSync(join(flagged, "supabase"), { recursive: true }); - await legacyRunUpgradeNotice({ - ...ctx.deps, - args: ["branches", "--workdir", flagged], - cleanShowHelp: true, - }); - // Go serves the bare group's help without ChangeWorkDir: nothing lands in - // the flagged project, and the caller's cwd (no supabase/) writes nothing. - expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); - expect(ctx.stderr).toContain("v2.114.0"); - ctx.cleanup(); - }); + it.effect("a clean group-help exit resolves against the bare cwd, ignoring --workdir", () => + Effect.gen(function* () { + const ctx = setup({ project: false }); + const flagged = join(workdir, "flagged"); + yield* makeDirectory(join(flagged, "supabase")); + yield* runNotice(ctx, { + ...ctx.deps, + args: ["branches", "--workdir", flagged], + cleanShowHelp: true, + }); + expect( + Option.isNone(yield* readTextOption(join(flagged, "supabase", ".temp", "cli-latest"))), + ).toBe(true); + expect(ctx.stderr).toContain("v2.114.0"); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("ignores a --workdir operand after the -- terminator, like cobra", async () => { - const ctx = setup({}); - const elsewhere = join(workdir, "elsewhere"); - mkdirSync(join(elsewhere, "supabase"), { recursive: true }); - const opCtx = { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }; - await legacyRunUpgradeNotice(opCtx); - // The operand is not a flag: the cache lands in the real project, not `elsewhere`. - expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); - expect(() => - readFileSync(join(elsewhere, "supabase", ".temp", "cli-latest"), "utf8"), - ).toThrow(); - ctx.cleanup(); - }); + it.effect("ignores a --workdir operand after the -- terminator, like cobra", () => + Effect.gen(function* () { + const ctx = setup({}); + const elsewhere = join(workdir, "elsewhere"); + yield* makeDirectory(join(elsewhere, "supabase")); + yield* runNotice(ctx, { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }); + expect(yield* readText(ctx.cachePath)).toBe("v2.114.0"); + expect( + Option.isNone(yield* readTextOption(join(elsewhere, "supabase", ".temp", "cli-latest"))), + ).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("never reads through or writes through a symlinked cache file", async () => { - const ctx = setup({}); - const victim = join(workdir, "victim.txt"); - writeFileSync(victim, "v9.9.9"); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - symlinkSync(victim, ctx.cachePath); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.fetchCalls).toBe(1); - expect(ctx.stderr).toContain("v2.114.0"); - expect(ctx.stderr).not.toContain("v9.9.9"); - expect(readFileSync(victim, "utf8")).toBe("v9.9.9"); - expect(lstatSync(ctx.cachePath).isSymbolicLink()).toBe(true); - ctx.cleanup(); - }); + it.effect("never reads through or writes through a symlinked cache file", () => + Effect.gen(function* () { + const ctx = setup({}); + const fs = yield* FileSystem.FileSystem; + yield* ctx.fixtures; + const victim = join(workdir, "victim.txt"); + yield* fs.writeFileString(victim, "v9.9.9"); + yield* makeDirectory(join(workdir, "supabase", ".temp")); + yield* fs.symlink(victim, ctx.cachePath); + yield* runNotice(ctx); + expect(ctx.fetchCalls).toBe(1); + expect(ctx.stderr).toContain("v2.114.0"); + expect(ctx.stderr).not.toContain("v9.9.9"); + expect(yield* readText(victim)).toBe("v9.9.9"); + expect(Option.isSome(yield* fs.readLink(ctx.cachePath).pipe(Effect.option))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("never writes through a cache symlink planted during the release fetch", async () => { - const ctx = setup({}); - const victim = join(workdir, "victim.txt"); - writeFileSync(victim, "v9.9.9"); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - - // The `lstat` guard runs BEFORE the fetch, so it only proves the path was - // safe up to FETCH_TIMEOUT_MS ago. Planting the symlink from inside - // `fetchLatestTag` lands it in exactly that check-then-write window — a - // concurrent process doing this made the old plain `writeFile` follow the - // link and truncate `victim`. The `O_NOFOLLOW` open fails with ELOOP. - await legacyRunUpgradeNotice({ - ...ctx.deps, - fetchLatestTag: () => { - symlinkSync(victim, ctx.cachePath); - return Promise.resolve("v2.114.0"); - }, - }); + it.effect("never writes through a cache symlink planted during the release fetch", () => + Effect.gen(function* () { + const ctx = setup({}); + const fs = yield* FileSystem.FileSystem; + yield* ctx.fixtures; + const victim = join(workdir, "victim.txt"); + yield* fs.writeFileString(victim, "v9.9.9"); + yield* makeDirectory(join(workdir, "supabase", ".temp")); + // Planting the symlink from inside the fetch lands it in the check-then-write window. + const fetchLatestTag = Effect.gen(function* () { + yield* fs.symlink(victim, ctx.cachePath); + return "v2.114.0"; + }).pipe( + Effect.mapError( + (cause) => new LegacyUpgradeNoticeError({ message: "fixture symlink", cause }), + ), + ); + yield* runNotice(ctx, { ...ctx.deps, fetchLatestTag }); + expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); + expect(yield* readText(victim)).toBe("v9.9.9"); + expect(Option.isSome(yield* fs.readLink(ctx.cachePath).pipe(Effect.option))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - expect(ctx.stderr).toContain("A new version of Supabase CLI is available: v2.114.0"); - expect(readFileSync(victim, "utf8")).toBe("v9.9.9"); - expect(lstatSync(ctx.cachePath).isSymbolicLink()).toBe(true); - ctx.cleanup(); - }); + it.effect("never writes through a cache symlink swapped after the final safety check", () => + Effect.gen(function* () { + const ctx = setup({}); + const fs = yield* FileSystem.FileSystem; + yield* ctx.fixtures; + const victim = join(workdir, "victim.txt"); + yield* fs.writeFileString(victim, "v9.9.9"); + yield* makeDirectory(join(workdir, "supabase", ".temp")); + let cacheChecks = 0; + const raceLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const real = yield* FileSystem.FileSystem; + return FileSystem.FileSystem.of({ + ...real, + readLink: (path: string) => { + if (path === ctx.cachePath) { + cacheChecks += 1; + if (cacheChecks === 2) { + return Effect.gen(function* () { + yield* real.symlink(victim, path); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readLink", + pathOrDescriptor: path, + }); + }); + } + } + return real.readLink(path); + }, + }); + }), + ).pipe(Layer.provide(BunServices.layer)); + yield* runNotice(ctx).pipe(Effect.provide(raceLayer)); + expect(ctx.fetchCalls).toBe(1); + expect(yield* readText(victim)).toBe("v9.9.9"); + expect(Option.isSome(yield* fs.readLink(ctx.cachePath).pipe(Effect.option))).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - it.each(["supabase", "supabase/.temp"])( + it.effect.each(["supabase", "supabase/.temp"])( "never writes through a symlinked %s directory", - async (linked) => { - const ctx = setup({ project: false }); - const outside = join(workdir, "outside"); - mkdirSync(outside, { recursive: true }); - if (linked !== "supabase") mkdirSync(join(workdir, "supabase"), { recursive: true }); - symlinkSync(outside, join(workdir, linked)); - - await legacyRunUpgradeNotice(ctx.deps); - - expect(ctx.stderr).toContain("v2.114.0"); - expect(readdirSync(outside)).toEqual([]); - ctx.cleanup(); - }, + (linked) => + Effect.gen(function* () { + const ctx = setup({ project: false }); + const fs = yield* FileSystem.FileSystem; + const outside = join(workdir, "outside"); + yield* makeDirectory(outside); + if (linked !== "supabase") yield* makeDirectory(join(workdir, "supabase")); + yield* fs.symlink(outside, join(workdir, linked)); + yield* runNotice(ctx); + expect(ctx.stderr).toContain("v2.114.0"); + expect(yield* fs.readDirectory(outside)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), ); - it("validates exact cache bytes and rejects embedded escape bytes", async () => { - for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { - const ctx = setup({ cacheContent, cacheAgeMs: 60_000 }); - await legacyRunUpgradeNotice(ctx.deps); - expect(ctx.stderr).toBe(""); - ctx.cleanup(); - } - }); + it.effect("validates exact cache bytes and rejects embedded escape bytes", () => + Effect.gen(function* () { + for (const cacheContent of ["v2.115.0\n", "v2.115.0\r\n", "v2.115.0\u001b[31mboo"]) { + const ctx = setup({ cacheContent, cacheAgeMs: 60_000 }); + yield* runNotice(ctx); + expect(ctx.stderr).toBe(""); + } + }).pipe(Effect.provide(BunServices.layer)), + ); }); /** @@ -645,48 +800,60 @@ describe("legacyRunUpgradeNotice", () => { * pinned to one breaks the moment it does. */ describe("legacyUpgradeNoticeHook", () => { - async function stderrFromHook(delegatedToGo: boolean): Promise<string> { - const workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-hook-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); - // Fresh cache: the hook reads it instead of fetching, so this never touches - // the network. - writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); - - const written: Array<string> = []; - const realWrite = process.stderr.write.bind(process.stderr); - const realOptOut = process.env["SUPABASE_NO_UPDATE_NOTIFIER"]; - process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = "0"; - process.stderr.write = ((chunk: unknown) => { - written.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - await Effect.runPromise( - legacyUpgradeNoticeHook(["db", "branch", "list"], { - cleanShowHelp: false, - delegatedToGo, - workingDirectory: workdir, - isValueTakingFlagToken: () => false, - }), + const tempRoot = useLegacyTempWorkdir("supabase-upgrade-notice-hook-"); + + const stderrFromHook = ( + delegatedToGo: boolean, + ): Effect.Effect<string, PlatformError.PlatformError> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = tempRoot.current; + yield* fs.makeDirectory(path.join(workdir, "supabase", ".temp"), { recursive: true }); + yield* fs.writeFileString( + path.join(workdir, "supabase", "config.toml"), + 'project_id = "demo"\n', ); - } finally { - process.stderr.write = realWrite; - if (realOptOut === undefined) delete process.env["SUPABASE_NO_UPDATE_NOTIFIER"]; - else process.env["SUPABASE_NO_UPDATE_NOTIFIER"] = realOptOut; - rmSync(workdir, { recursive: true, force: true }); - } - return stripVTControlCharacters(written.join("")); - } + // Fresh cache: the hook reads it instead of fetching, so this never touches the network. + yield* fs.writeFileString(path.join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); + + const written: Array<string> = []; + const realWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = ((chunk: unknown) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + yield* legacyUpgradeNoticeHook(["db", "branch", "list"], { + cleanShowHelp: false, + delegatedToGo, + workingDirectory: workdir, + isValueTakingFlagToken: () => false, + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" } }), + ), + ), + Effect.ensuring( + Effect.sync(() => { + process.stderr.write = realWrite; + }), + ), + ); + return stripVTControlCharacters(written.join("")); + }).pipe(Effect.provide(BunServices.layer)); - it("prints the notice for a natively handled command", async () => { - expect(await stderrFromHook(false)).toContain( - "A new version of Supabase CLI is available: v99.99.99", - ); - }); + it.effect("prints the notice for a natively handled command", () => + Effect.gen(function* () { + expect(yield* stderrFromHook(false)).toContain( + "A new version of Supabase CLI is available: v99.99.99", + ); + }), + ); - it("stays silent when the run delegated to Go, which printed its own notice", async () => { - expect(await stderrFromHook(true)).toBe(""); - }); + it.effect("stays silent when the run delegated to Go, which printed its own notice", () => + Effect.gen(function* () { + expect(yield* stderrFromHook(true)).toBe(""); + }), + ); }); diff --git a/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts b/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts index 1a9ccabeaf..cce0e33c0e 100644 --- a/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-analytics.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import { Config, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { aiToolLayer } from "../../shared/telemetry/ai-tool.layer.ts"; import { AiTool } from "../../shared/telemetry/ai-tool.service.ts"; import { @@ -37,6 +37,13 @@ interface LinkedProjectCacheValue { readonly organization_slug: string; } +const LinkedProjectCacheSchema = Schema.Struct({ + ref: Schema.String, + name: Schema.optional(Schema.String), + organization_id: Schema.optional(Schema.String), + organization_slug: Schema.String, +}); + function stripUndefined(properties: Record<string, unknown>): Record<string, unknown> { return Object.fromEntries(Object.entries(properties).filter(([, value]) => value !== undefined)); } @@ -78,18 +85,20 @@ export function resolveGroups( } // Mirrors apps/cli-go/cmd/root_analytics.go:149-165 envSignals(). -export function collectEnvSignals(): Record<string, true | string> | undefined { +export function collectEnvSignals( + env: Readonly<Record<string, string | undefined>>, +): Record<string, true | string> | undefined { const signals: Record<string, true | string> = {}; for (const key of EnvSignalPresenceKeys) { - const raw = process.env[key]; + const raw = env[key]; if (raw === undefined) continue; if (raw.trim().length === 0) continue; signals[key] = true; } for (const key of EnvSignalValueKeys) { - const raw = process.env[key]; + const raw = env[key]; if (raw === undefined) continue; const trimmed = raw.trim(); if (trimmed.length === 0) continue; @@ -113,8 +122,8 @@ export function collectEnvSignals(): Record<string, true | string> | undefined { function makeLoadLinkedProject( fs: FileSystem.FileSystem, path: Path.Path, + workdir: string, ): Effect.Effect<Option.Option<LinkedProjectCacheValue>> { - const workdir = process.env.SUPABASE_WORKDIR ?? process.cwd(); const cachePath = path.join(workdir, "supabase", ".temp", "linked-project.json"); return Effect.gen(function* () { const exists = yield* fs.exists(cachePath).pipe(Effect.orElseSucceed(() => false)); @@ -123,21 +132,20 @@ function makeLoadLinkedProject( const content = yield* fs.readFileString(cachePath).pipe(Effect.option); if (Option.isNone(content)) return Option.none<LinkedProjectCacheValue>(); - try { - const parsed = JSON.parse(content.value) as Partial<LinkedProjectCacheValue>; - if (typeof parsed.ref !== "string" || typeof parsed.organization_slug !== "string") { - return Option.none<LinkedProjectCacheValue>(); - } - return Option.some<LinkedProjectCacheValue>({ - ref: parsed.ref, - name: typeof parsed.name === "string" ? parsed.name : "", - organization_id: typeof parsed.organization_id === "string" ? parsed.organization_id : "", - organization_slug: parsed.organization_slug, - }); - } catch { - return Option.none<LinkedProjectCacheValue>(); - } - }).pipe(Effect.catch(() => Effect.succeed(Option.none<LinkedProjectCacheValue>()))); + return yield* Schema.decodeEffect(Schema.fromJsonString(LinkedProjectCacheSchema))( + content.value, + ).pipe( + Effect.map(({ ref, name, organization_id, organization_slug }) => + Option.some<LinkedProjectCacheValue>({ + ref, + name: name ?? "", + organization_id: organization_id ?? "", + organization_slug, + }), + ), + Effect.orElseSucceed(() => Option.none<LinkedProjectCacheValue>()), + ); + }); } export const legacyAnalyticsLayer = Layer.effect( @@ -147,7 +155,24 @@ export const legacyAnalyticsLayer = Layer.effect( const aiTool = yield* AiTool; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const posthogConfig = resolvePosthogConfig(process.env); + const envKeys = [ + ...EnvSignalPresenceKeys, + ...EnvSignalValueKeys, + "SUPABASE_WORKDIR", + "SUPABASE_TELEMETRY_POSTHOG_HOST", + "SUPABASE_TELEMETRY_POSTHOG_KEY", + "SUPABASE_CLI_POSTHOG_HOST", + "SUPABASE_CLI_POSTHOG_KEY", + ]; + const envEntries = yield* Effect.all( + [...new Set(envKeys)].map((key) => + Config.option(Config.string(key)).pipe( + Effect.map((value) => [key, Option.getOrUndefined(value)] as const), + ), + ), + ); + const env = Object.fromEntries(envEntries); + const posthogConfig = resolvePosthogConfig(env); if (runtime.consent !== "granted" || Option.isNone(posthogConfig.key)) { return Analytics.of({ @@ -160,10 +185,14 @@ export const legacyAnalyticsLayer = Layer.effect( const client = yield* scopedPosthogClient(posthogConfig.key.value, posthogConfig.host); - const loadLinkedProject = makeLoadLinkedProject(fs, path); + const loadLinkedProject = makeLoadLinkedProject( + fs, + path, + env.SUPABASE_WORKDIR ?? process.cwd(), + ); const isAgent = Option.isSome(aiTool.name); - const envSignals = collectEnvSignals(); + const envSignals = collectEnvSignals(env); const baseProperties = stripUndefined({ [PropPlatform]: "cli", diff --git a/apps/cli/src/legacy/telemetry/legacy-analytics.layer.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-analytics.layer.unit.test.ts index b609d1e39e..735f7af319 100644 --- a/apps/cli/src/legacy/telemetry/legacy-analytics.layer.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-analytics.layer.unit.test.ts @@ -1,8 +1,6 @@ import { Option } from "effect"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - EnvSignalPresenceKeys, - EnvSignalValueKeys, GroupOrganization, GroupProject, MaxEnvSignalValueLength, @@ -17,47 +15,13 @@ const linkedCacheValue = (over: Partial<Record<string, string>> = {}) => ({ ...over, }); -const RESET_KEYS = [...EnvSignalPresenceKeys, ...EnvSignalValueKeys]; - -function snapshotEnv() { - const original: Record<string, string | undefined> = {}; - for (const key of RESET_KEYS) { - original[key] = process.env[key]; - delete process.env[key]; - } - return original; -} - -function restoreEnv(original: Record<string, string | undefined>) { - for (const [key, value] of Object.entries(original)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - describe("collectEnvSignals", () => { - let original: Record<string, string | undefined>; - - beforeEach(() => { - original = snapshotEnv(); - }); - - afterEach(() => { - restoreEnv(original); - }); - it("returns undefined when no relevant env vars are set", () => { - expect(collectEnvSignals()).toBeUndefined(); + expect(collectEnvSignals({})).toBeUndefined(); }); it("records presence keys as boolean `true`", () => { - process.env.CI = "1"; - process.env.CLAUDECODE = "true"; - - const signals = collectEnvSignals(); + const signals = collectEnvSignals({ CI: "1", CLAUDECODE: "true" }); expect(signals).toEqual({ CI: true, CLAUDECODE: true, @@ -65,10 +29,7 @@ describe("collectEnvSignals", () => { }); it("records value keys as trimmed strings", () => { - process.env.AI_AGENT = " claude-code "; - process.env.TERM = "xterm-256color"; - - const signals = collectEnvSignals(); + const signals = collectEnvSignals({ AI_AGENT: " claude-code ", TERM: "xterm-256color" }); expect(signals).toEqual({ AI_AGENT: "claude-code", TERM: "xterm-256color", @@ -77,25 +38,18 @@ describe("collectEnvSignals", () => { it("caps value-key strings at MaxEnvSignalValueLength chars", () => { const long = "a".repeat(MaxEnvSignalValueLength + 50); - process.env.AI_AGENT = long; - - const signals = collectEnvSignals(); + const signals = collectEnvSignals({ AI_AGENT: long }); const aiAgent = signals?.AI_AGENT; expect(aiAgent).toBe("a".repeat(MaxEnvSignalValueLength)); expect(typeof aiAgent === "string" ? aiAgent.length : -1).toBe(MaxEnvSignalValueLength); }); it("skips presence keys with empty/whitespace-only values", () => { - process.env.CI = ""; - process.env.GITHUB_ACTIONS = " "; - - expect(collectEnvSignals()).toBeUndefined(); + expect(collectEnvSignals({ CI: "", GITHUB_ACTIONS: " " })).toBeUndefined(); }); it("skips value keys with empty/whitespace-only values", () => { - process.env.AI_AGENT = " "; - - expect(collectEnvSignals()).toBeUndefined(); + expect(collectEnvSignals({ AI_AGENT: " " })).toBeUndefined(); }); }); diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts index 8f2dced990..fd8b55f5a0 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.ts @@ -120,11 +120,9 @@ export const legacyValidateOutputFormat = (allowed: ReadonlyArray<string>) => if (Option.isNone(flag) || Option.isNone(flag.value)) return; const value = flag.value.value; if (allowed.includes(value)) return; - return yield* Effect.fail( - new LegacyInvalidOutputFormatError({ - message: legacyInvalidOutputFormatMessage(value, allowed), - }), - ); + return yield* new LegacyInvalidOutputFormatError({ + message: legacyInvalidOutputFormatMessage(value, allowed), + }); }); const REDACTED_VALUE = "<redacted>"; @@ -238,7 +236,7 @@ function extractChangedFlagNames( return [...used].sort((left, right) => left.localeCompare(right)); } -function normalizeFlagValue(value: unknown): unknown | undefined { +function normalizeFlagValue(value: unknown): unknown { if (value === undefined) return undefined; if (!Option.isOption(value)) return value; if (Option.isNone(value)) return undefined; diff --git a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts index d376e93200..a9e32994cf 100644 --- a/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-command-instrumentation.unit.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Effect, Exit, Formatter, Layer, Option, Stdio } from "effect"; import { Flag } from "effect/unstable/cli"; -import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { LegacyAgentFlag, LegacyDebugFlag, @@ -30,6 +31,9 @@ import { } from "../shared/legacy-go-output-flag.ts"; import { mockOutput, mockProcessControl } from "../../../tests/helpers/mocks.ts"; +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + const FAILURE_PROPERTY_NAMES = [ PropErrorKind, PropErrorCategory, @@ -116,15 +120,17 @@ describe("withLegacyCommandInstrumentation", () => { expect(typeof span.attributes.get("command_run_id")).toBe("string"); }).pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list"]), + }), + commandRuntimeLayer(["backups", "list"]), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -147,15 +153,17 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--output", "yaml"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--output", "yaml"]), + }), + commandRuntimeLayer(["backups", "list"]), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("yaml"); @@ -169,22 +177,24 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "json" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "backups", - "list", - "--output", - "pretty", - "--output-format", - "json", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "backups", + "list", + "--output", + "pretty", + "--output-format", + "json", + ]), + }), + commandRuntimeLayer(["backups", "list"]), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties.output_format).toBe("json"); @@ -200,15 +210,17 @@ describe("withLegacyCommandInstrumentation", () => { withLegacyCommandInstrumentation({ flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["secrets", "list", "--project-ref", "abcdefghijklmnopqrst"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["secrets", "list", "--project-ref", "abcdefghijklmnopqrst"]), + }), + commandRuntimeLayer(["secrets", "list"]), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -228,15 +240,17 @@ describe("withLegacyCommandInstrumentation", () => { withLegacyCommandInstrumentation({ flags: { envFile: Option.some("/path/to/.env") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["secrets", "set", "--env-file=/path/to/.env"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["secrets", "set", "--env-file=/path/to/.env"]), + }), + commandRuntimeLayer(["secrets", "set"]), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -253,13 +267,15 @@ describe("withLegacyCommandInstrumentation", () => { withLegacyCommandInstrumentation({ flags: { password: Option.some("super-secret") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--password", "super-secret"]) }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump", "--password", "super-secret"]) }), + commandRuntimeLayer(["db", "dump"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "dump"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -280,15 +296,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { schema: Option.some(["public"]) }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "-s", "public"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "-s", "public"]), + }), + commandRuntimeLayer(["db", "lint"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -309,15 +327,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { exclude: ["public.users"], file: Option.some("out.sql") }, aliases: { s: "schema", x: "exclude", f: "file", p: "password" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "dump", "-x", "public.users", "-f", "out.sql"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "dump", "-x", "public.users", "-f", "out.sql"]), + }), + commandRuntimeLayer(["db", "dump"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "dump"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -337,15 +357,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { file: Option.some("query.sql") }, aliases: { f: "file" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "query", "-f", "query.sql"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "query", "-f", "query.sql"]), + }), + commandRuntimeLayer(["db", "query"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "query"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -365,24 +387,26 @@ describe("withLegacyCommandInstrumentation", () => { flags: { schema: ["public"], password: Option.some("secret") }, aliases: { s: "schema", p: "password" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "db", - "schema", - "declarative", - "generate", - "-s", - "public", - "-p", - "secret", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "db", + "schema", + "declarative", + "generate", + "-s", + "public", + "-p", + "secret", + ]), + }), + commandRuntimeLayer(["db", "schema", "declarative", "generate"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "schema", "declarative", "generate"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -402,24 +426,26 @@ describe("withLegacyCommandInstrumentation", () => { flags: { schema: ["public"], file: Option.some("out.sql") }, aliases: { s: "schema", f: "file" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "db", - "schema", - "declarative", - "sync", - "-s", - "public", - "-f", - "out.sql", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "db", + "schema", + "declarative", + "sync", + "-s", + "public", + "-f", + "out.sql", + ]), + }), + commandRuntimeLayer(["db", "schema", "declarative", "sync"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "schema", "declarative", "sync"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -439,20 +465,22 @@ describe("withLegacyCommandInstrumentation", () => { disableDbSslEnforcement: false, }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "ssl-enforcement", - "update", - "--enable-db-ssl-enforcement", - "--disable-db-ssl-enforcement", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "ssl-enforcement", + "update", + "--enable-db-ssl-enforcement", + "--disable-db-ssl-enforcement", + ]), + }), + commandRuntimeLayer(["ssl-enforcement", "update"]), + ), ), - Effect.provide(commandRuntimeLayer(["ssl-enforcement", "update"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -473,15 +501,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, safeFlags: ["project-ref"], }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["link", "--project-ref", "abcdefghijklmnopqrst"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["link", "--project-ref", "abcdefghijklmnopqrst"]), + }), + commandRuntimeLayer(["link"]), + ), ), - Effect.provide(commandRuntimeLayer(["link"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -504,13 +534,15 @@ describe("withLegacyCommandInstrumentation", () => { flags: { lang: "python" }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["gen", "types", "--lang", "python"]) }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["gen", "types", "--lang", "python"]) }), + commandRuntimeLayer(["gen", "types"]), + ), ), - Effect.provide(commandRuntimeLayer(["gen", "types"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -537,13 +569,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { algorithm: "RS256" }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "RS256"]) }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["gen", "signing-key", "--algorithm", "RS256"]), + }), + commandRuntimeLayer(["gen", "signing-key"]), + ), ), - Effect.provide(commandRuntimeLayer(["gen", "signing-key"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -571,11 +607,15 @@ describe("withLegacyCommandInstrumentation", () => { config, aliases: { t: "type" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["sso", "add", "-t", "saml"]) })), - Effect.provide(commandRuntimeLayer(["sso", "add"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["sso", "add", "-t", "saml"]) }), + commandRuntimeLayer(["sso", "add"]), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -597,13 +637,15 @@ describe("withLegacyCommandInstrumentation", () => { flags: { algorithm: Option.some("ES256") }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "ES256"]) }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["gen", "signing-key", "--algorithm", "ES256"]) }), + commandRuntimeLayer(["gen", "signing-key"]), + ), ), - Effect.provide(commandRuntimeLayer(["gen", "signing-key"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -625,15 +667,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { lang: "go", schema: "public" }, config, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["gen", "types", "--lang", "go", "--schema", "public"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["gen", "types", "--lang", "go", "--schema", "public"]), + }), + commandRuntimeLayer(["gen", "types"]), + ), ), - Effect.provide(commandRuntimeLayer(["gen", "types"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -648,11 +692,15 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -669,11 +717,15 @@ describe("withLegacyCommandInstrumentation", () => { return withLegacyCommandInstrumentation()( Effect.fail(new LegacyDbDumpRunError({ message: secret })), ).pipe( - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]), + ), + ), Effect.exit, Effect.tap(() => Effect.sync(() => { @@ -688,7 +740,7 @@ describe("withLegacyCommandInstrumentation", () => { }); expect(analytics.captured[0]?.properties).not.toHaveProperty(PropSuggestedCommand); expect(analytics.captured[0]?.properties).not.toHaveProperty(PropWorkflow); - expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); + expect(Formatter.formatJson(analytics.captured[0])).not.toContain(secret); }), ), Effect.asVoid, @@ -700,11 +752,15 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.fail(failure).pipe( withLegacyCommandInstrumentation(), - Effect.provide(failingAnalytics(new Error("telemetry defect"))), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide( + Layer.mergeAll( + failingAnalytics(new Error("telemetry defect")), + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) }), + commandRuntimeLayer(["db", "dump"]), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -725,11 +781,15 @@ describe("withLegacyCommandInstrumentation", () => { // is being cancelled and swallowing would fight the cancellation. return Effect.void.pipe( withLegacyCommandInstrumentation(), - Effect.provide(interruptingAnalytics()), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide( + Layer.mergeAll( + interruptingAnalytics(), + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) }), + commandRuntimeLayer(["db", "dump"]), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -757,11 +817,15 @@ describe("withLegacyCommandInstrumentation", () => { yield* pc.setExitCode(1); }).pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "lint"]) })), - Effect.provide(commandRuntimeLayer(["db", "lint"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "lint"]) }), + commandRuntimeLayer(["db", "lint"]), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -787,11 +851,15 @@ describe("withLegacyCommandInstrumentation", () => { yield* pc.setExitCode(1); }).pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) })), - Effect.provide(commandRuntimeLayer(["db", "advisors"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "advisors"]) }), + commandRuntimeLayer(["db", "advisors"]), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -826,11 +894,15 @@ describe("withLegacyCommandInstrumentation", () => { yield* pc.setExitCode(1); }), ), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "json" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) })), - Effect.provide(commandRuntimeLayer(["db", "dump"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "json" }).layer, + Stdio.layerTest({ args: Effect.succeed(["db", "dump"]) }), + commandRuntimeLayer(["db", "dump"]), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -854,11 +926,15 @@ describe("withLegacyCommandInstrumentation", () => { yield* pc.setExitCode(2); }).pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(processControl.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) })), - Effect.provide(commandRuntimeLayer(["unknown", "command"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + processControl.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["unknown", "command"]) }), + commandRuntimeLayer(["unknown", "command"]), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured[0]?.properties).toMatchObject({ @@ -877,13 +953,17 @@ describe("withLegacyCommandInstrumentation", () => { it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withLegacyCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) })), - Effect.provide(commandRuntimeLayer(["telemetry", "enable"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["telemetry", "enable"]) }), + commandRuntimeLayer(["telemetry", "enable"]), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toEqual([]); @@ -902,21 +982,23 @@ describe("withLegacyCommandInstrumentation", () => { timestamp: Option.some(1707407047), }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "backups", - "restore", - "--timestamp=1707407047", - "--project-ref", - "abcdefghijklmnopqrst", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "backups", + "restore", + "--timestamp=1707407047", + "--project-ref", + "abcdefghijklmnopqrst", + ]), + }), + commandRuntimeLayer(["backups", "restore"]), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "restore"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -931,15 +1013,18 @@ describe("withLegacyCommandInstrumentation", () => { it.live("rejects an -o value outside the command's enum, before running it", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "must not run").pipe( + return Effect.succeed("must not run").pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "table"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), - // `table` is valid on the shared global union but not for a resource command. - Effect.provide(Layer.succeed(LegacyOutputFlag, Option.some("table" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + mockProcessControl().layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "table"]) }), + commandRuntimeLayer(["backups", "list"]), + Layer.succeed(LegacyOutputFlag, Option.some("table" as const)), + ), + ), Effect.flip, Effect.tap((error) => Effect.sync(() => { @@ -957,14 +1042,18 @@ describe("withLegacyCommandInstrumentation", () => { it.live("accepts a command-specific -o value declared via outputFormats", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withLegacyCommandInstrumentation({ flags: {}, outputFormats: LEGACY_QUERY_OUTPUT_FORMATS }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "csv"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"])), - Effect.provide(Layer.succeed(LegacyOutputFlag, Option.some("csv" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + mockProcessControl().layer, + Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "csv"]) }), + commandRuntimeLayer(["db", "query"]), + Layer.succeed(LegacyOutputFlag, Option.some("csv" as const)), + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -984,12 +1073,16 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"])), - Effect.provide(stitch.layer), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["link"]) }), + commandRuntimeLayer(["link"]), + stitch.layer, + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1002,14 +1095,18 @@ describe("withLegacyCommandInstrumentation", () => { it.live("rejects a resource-only -o value for db query's narrower enum", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "must not run").pipe( + return Effect.succeed("must not run").pipe( withLegacyCommandInstrumentation({ flags: {}, outputFormats: LEGACY_QUERY_OUTPUT_FORMATS }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "yaml"]) })), - Effect.provide(commandRuntimeLayer(["db", "query"])), - Effect.provide(Layer.succeed(LegacyOutputFlag, Option.some("yaml" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + mockProcessControl().layer, + Stdio.layerTest({ args: Effect.succeed(["db", "query", "-o", "yaml"]) }), + commandRuntimeLayer(["db", "query"]), + Layer.succeed(LegacyOutputFlag, Option.some("yaml" as const)), + ), + ), Effect.flip, Effect.tap((error) => Effect.sync(() => { @@ -1027,12 +1124,16 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["link"]) })), - Effect.provide(commandRuntimeLayer(["link"])), - Effect.provide(stitch.layer), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["link"]) }), + commandRuntimeLayer(["link"]), + stitch.layer, + ), + ), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1051,11 +1152,15 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) }), + commandRuntimeLayer(["backups", "list"]), + ), + ), // Note: no stitch layer provided — serviceOption must default to None Effect.tap(() => Effect.sync(() => { @@ -1082,15 +1187,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { schema: Option.some(["--linked"]) }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "--schema", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "--schema", "--linked"]), + }), + commandRuntimeLayer(["db", "lint"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1111,15 +1218,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { schema: Option.some(["public"]), linked: true }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "--schema=public", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "--schema=public", "--linked"]), + }), + commandRuntimeLayer(["db", "lint"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1140,15 +1249,17 @@ describe("withLegacyCommandInstrumentation", () => { flags: { schema: Option.some(["public"]), linked: true }, aliases: { s: "schema" }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "-s", "public", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "-s", "public", "--linked"]), + }), + commandRuntimeLayer(["db", "lint"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1170,15 +1281,17 @@ describe("withLegacyCommandInstrumentation", () => { withLegacyCommandInstrumentation({ flags: { dbUrl: Option.some("x"), local: true }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "lint", "--db-url", "x", "--local"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "lint", "--db-url", "x", "--local"]), + }), + commandRuntimeLayer(["db", "lint"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "lint"])), Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; @@ -1206,12 +1319,16 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), - Effect.provide(Layer.succeed(LegacyDebugFlag, true)), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) }), + commandRuntimeLayer(["backups", "list"]), + Layer.succeed(LegacyDebugFlag, true), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1228,22 +1345,24 @@ describe("withLegacyCommandInstrumentation", () => { withLegacyCommandInstrumentation({ flags: { projectRef: Option.some("abcdefghijklmnopqrst") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "secrets", - "list", - "--project-ref", - "abcdefghijklmnopqrst", - "--debug", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "secrets", + "list", + "--project-ref", + "abcdefghijklmnopqrst", + "--debug", + ]), + }), + commandRuntimeLayer(["secrets", "list"]), + Layer.succeed(LegacyDebugFlag, true), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "list"])), - Effect.provide(Layer.succeed(LegacyDebugFlag, true)), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1263,16 +1382,18 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--workdir", "/tmp/project"]), + }), + commandRuntimeLayer(["backups", "list"]), + Layer.succeed(LegacyWorkdirFlag, Option.some("/tmp/project")), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), - Effect.provide(Layer.succeed(LegacyWorkdirFlag, Option.some("/tmp/project"))), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1290,16 +1411,18 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--dns-resolver", "https"]), + }), + commandRuntimeLayer(["backups", "list"]), + Layer.succeed(LegacyDnsResolverFlag, "https" as const), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), - Effect.provide(Layer.succeed(LegacyDnsResolverFlag, "https" as const)), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1317,16 +1440,18 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["backups", "list", "--agent", "yes"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["backups", "list", "--agent", "yes"]), + }), + commandRuntimeLayer(["backups", "list"]), + Layer.succeed(LegacyAgentFlag, "yes" as const), + ), ), - Effect.provide(commandRuntimeLayer(["backups", "list"])), - Effect.provide(Layer.succeed(LegacyAgentFlag, "yes" as const)), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1352,15 +1477,17 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: { output: "diff.sql" } }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["db", "diff", "--output", "diff.sql"]), + }), + commandRuntimeLayer(["db", "diff"]), + ), ), - Effect.provide(commandRuntimeLayer(["db", "diff"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1380,11 +1507,15 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) }), + commandRuntimeLayer(["backups", "list"]), + ), + ), // Note: no LegacyDebugFlag layer provided. Effect.tap(() => Effect.sync(() => { @@ -1402,15 +1533,17 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["test", "db", "--", "--linked"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["test", "db", "--", "--linked"]), + }), + commandRuntimeLayer(["test", "db"]), + ), ), - Effect.provide(commandRuntimeLayer(["test", "db"])), Effect.tap(() => Effect.sync(() => { // No changed flags → the flags map is omitted entirely; `--linked` @@ -1434,12 +1567,16 @@ describe("withLegacyCommandInstrumentation", () => { return Effect.void.pipe( withLegacyCommandInstrumentation({ flags: {} }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) })), - Effect.provide(commandRuntimeLayer(["backups", "list"])), - Effect.provide(Layer.succeed(LegacyOutputFlag, Option.some("json" as const))), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["backups", "list", "-o", "json"]) }), + commandRuntimeLayer(["backups", "list"]), + Layer.succeed(LegacyOutputFlag, Option.some("json" as const)), + ), + ), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1470,15 +1607,17 @@ describe("withLegacyCommandInstrumentation", () => { withLegacyCommandInstrumentation({ flags: { envFile: Option.some("--debug") }, }), - Effect.provide(analytics.layer), - Effect.provide(mockProcessControl().layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), - }), + Layer.mergeAll( + analytics.layer, + mockProcessControl().layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["secrets", "set", "--env-file", "--debug"]), + }), + commandRuntimeLayer(["secrets", "set"]), + ), ), - Effect.provide(commandRuntimeLayer(["secrets", "set"])), Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.integration.test.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.integration.test.ts index 3e910a65ba..f5dddf25c3 100644 --- a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.integration.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.integration.test.ts @@ -1,9 +1,6 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockTelemetryRuntime } from "../../../tests/helpers/mocks.ts"; @@ -12,19 +9,35 @@ import { mockLegacyCliConfig, mockLegacyCredentialsLayer, mockLegacyPlatformApi, + useLegacyTempWorkdir, } from "../../../tests/helpers/legacy-mocks.ts"; import { legacyIdentityStitchLayer } from "../shared/legacy-identity-stitch.ts"; import { legacyLinkedProjectCacheLayer } from "./legacy-linked-project-cache.layer.ts"; import { LegacyLinkedProjectCache } from "./legacy-linked-project-cache.service.ts"; describe("legacyLinkedProjectCacheLayer", () => { + const temp = useLegacyTempWorkdir("legacy-linked-cache-"); + const path = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); + const LinkedProjectCacheSchema = Schema.Struct({ + ref: Schema.String, + name: Schema.String, + organization_id: Schema.String, + organization_slug: Schema.String, + }); + const encodeLinkedProject = Schema.encodeUnknownSync( + Schema.fromJsonString(LinkedProjectCacheSchema), + ); + const decodeLinkedProject = Schema.decodeUnknownSync( + Schema.fromJsonString(LinkedProjectCacheSchema), + ); + it.live( "stitches session identity from the cache GET's X-Gotrue-Id (Go identityTransport)", () => { // Go runs ensureProjectGroupsCached's GET through GetSupabase()'s // identityTransport, so the X-Gotrue-Id stitches the session identity — the // only stitch opportunity for a password-only `--linked` run. Mirror that here. - const workdir = mkdtempSync(join(tmpdir(), "legacy-linked-cache-")); + const workdir = temp.current; const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ handler: (request) => @@ -32,7 +45,7 @@ describe("legacyLinkedProjectCacheLayer", () => { HttpClientResponse.fromWeb( request, new Response( - JSON.stringify({ + encodeLinkedProject({ ref: LEGACY_VALID_REF, name: "proj", organization_id: "org-1", @@ -53,7 +66,7 @@ describe("legacyLinkedProjectCacheLayer", () => { Layer.provide(analytics.layer), Layer.provide( mockTelemetryRuntime({ - configDir: join(workdir, ".supabase"), + configDir: path.join(workdir, ".supabase"), consent: "granted", distinctId: undefined, isCi: false, @@ -77,12 +90,13 @@ describe("legacyLinkedProjectCacheLayer", () => { const cache = yield* LegacyLinkedProjectCache; yield* cache.cache(LEGACY_VALID_REF, workdir); // Identity stitched from the cache response's X-Gotrue-Id. - expect(JSON.stringify(analytics.aliased)).toContain("gotrue-abc"); + expect(analytics.aliased.some(({ distinctId }) => distinctId === "gotrue-abc")).toBe(true); // The linked-project cache is still written. - const written: unknown = JSON.parse( - readFileSync(join(workdir, "supabase", ".temp", "linked-project.json"), "utf8"), + const fs = yield* FileSystem.FileSystem; + const written = yield* fs.readFileString( + path.join(workdir, "supabase", ".temp", "linked-project.json"), ); - expect((written as { ref: string }).ref).toBe(LEGACY_VALID_REF); + expect(decodeLinkedProject(written).ref).toBe(LEGACY_VALID_REF); // Go's CacheProjectAndIdentifyGroups also publishes org + project groups on // the same cache miss (telemetry/project.go:66-88). expect(analytics.groupIdentified).toEqual([ @@ -97,25 +111,14 @@ describe("legacyLinkedProjectCacheLayer", () => { properties: { name: "proj", organization_slug: "acme" }, }, ]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer))); }, ); it.live("does not re-identify groups when the linked-project cache already exists", () => { // Cache hit → Go's HasLinkedProject guard returns early, so no write and no // GroupIdentify. The TS `exists` early-return must match. - const workdir = mkdtempSync(join(tmpdir(), "legacy-linked-cache-hit-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync( - join(workdir, "supabase", ".temp", "linked-project.json"), - JSON.stringify({ - ref: LEGACY_VALID_REF, - name: "proj", - organization_id: "org-1", - organization_slug: "acme", - }), - ); + const workdir = temp.current; const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ handler: () => Effect.die("cache GET must not run on a cache hit"), @@ -123,7 +126,7 @@ describe("legacyLinkedProjectCacheLayer", () => { const identityStitch = legacyIdentityStitchLayer.pipe( Layer.provide(analytics.layer), Layer.provide( - mockTelemetryRuntime({ configDir: join(workdir, ".supabase"), consent: "granted" }), + mockTelemetryRuntime({ configDir: path.join(workdir, ".supabase"), consent: "granted" }), ), Layer.provide(BunServices.layer), ); @@ -136,12 +139,23 @@ describe("legacyLinkedProjectCacheLayer", () => { Layer.provide(BunServices.layer), ); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = path.join(workdir, "supabase", ".temp", "linked-project.json"); + yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); + yield* fs.writeFileString( + cachePath, + encodeLinkedProject({ + ref: LEGACY_VALID_REF, + name: "proj", + organization_id: "org-1", + organization_slug: "acme", + }), + ); const cache = yield* LegacyLinkedProjectCache; yield* cache.cache(LEGACY_VALID_REF, workdir); expect(analytics.groupIdentified).toEqual([]); expect(analytics.aliased).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer))); }); it.live( @@ -152,9 +166,7 @@ describe("legacyLinkedProjectCacheLayer", () => { // would make the parent chain prefer a never-linked project. The guard // runs before any token/network work, so the GET must never fire. const OTHER_REF = "otherprojectrefabcde"; - const workdir = mkdtempSync(join(tmpdir(), "legacy-linked-cache-diverge-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", ".temp", "project-ref"), LEGACY_VALID_REF); + const workdir = temp.current; const analytics = mockAnalytics(); const api = mockLegacyPlatformApi({ handler: () => Effect.die("cache GET must not run when project-ref diverges"), @@ -162,7 +174,7 @@ describe("legacyLinkedProjectCacheLayer", () => { const identityStitch = legacyIdentityStitchLayer.pipe( Layer.provide(analytics.layer), Layer.provide( - mockTelemetryRuntime({ configDir: join(workdir, ".supabase"), consent: "granted" }), + mockTelemetryRuntime({ configDir: path.join(workdir, ".supabase"), consent: "granted" }), ), Layer.provide(BunServices.layer), ); @@ -175,12 +187,17 @@ describe("legacyLinkedProjectCacheLayer", () => { Layer.provide(BunServices.layer), ); return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const refPath = path.join(workdir, "supabase", ".temp", "project-ref"); + yield* fs.makeDirectory(path.dirname(refPath), { recursive: true }); + yield* fs.writeFileString(refPath, LEGACY_VALID_REF); const cache = yield* LegacyLinkedProjectCache; yield* cache.cache(OTHER_REF, workdir); - expect(existsSync(join(workdir, "supabase", ".temp", "linked-project.json"))).toBe(false); + expect( + yield* fs.exists(path.join(workdir, "supabase", ".temp", "linked-project.json")), + ).toBe(false); expect(analytics.groupIdentified).toEqual([]); - rmSync(workdir, { recursive: true, force: true }); - }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(Layer.mergeAll(layer, BunServices.layer))); }, ); }); diff --git a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts index 63f88fd139..a129c80cd1 100644 --- a/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-linked-project-cache.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -10,6 +10,13 @@ import { GroupOrganization, GroupProject } from "../../shared/telemetry/event-ca import { legacyReadProjectRefFile, legacyTempPaths } from "../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "./legacy-linked-project-cache.service.ts"; +const LinkedProjectCacheSchema = Schema.Struct({ + ref: Schema.String, + name: Schema.String, + organization_id: Schema.String, + organization_slug: Schema.String, +}); + function readString(obj: unknown, key: string): string { if (typeof obj === "object" && obj !== null && key in obj) { const value = (obj as Record<string, unknown>)[key]; @@ -136,7 +143,10 @@ export const legacyLinkedProjectCacheLayer = Layer.effect( }; yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); - yield* fs.writeFileString(cachePath, JSON.stringify(linked)); + const encoded = yield* Schema.encodeEffect( + Schema.fromJsonString(LinkedProjectCacheSchema), + )(linked); + yield* fs.writeFileString(cachePath, encoded); // Go's CacheProjectAndIdentifyGroups (telemetry/project.go:66-88) does // not just write the file — on the same cache miss it also publishes the diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts index 88038afc49..47c6c7c405 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Config, Crypto, DateTime, Effect, FileSystem, Layer, Option, Path } from "effect"; import { homedir } from "node:os"; import { Analytics } from "../../shared/telemetry/analytics.service.ts"; @@ -27,9 +27,10 @@ interface State { const SCHEMA_VERSION = 1; const SESSION_ROTATION_MS = 30 * 60 * 1000; -function legacyTelemetryPath(env: Record<string, string | undefined>, pathSvc: Path.Path): string { - return pathSvc.join(legacySupabaseHome(homedir(), env), "telemetry.json"); -} +const legacyTelemetryPath = (pathSvc: Path.Path, configuredHome: string | undefined): string => + pathSvc.join(legacySupabaseHome(pathSvc, configuredHome, homedir()), "telemetry.json"); + +const resolveTelemetryHome = Config.option(Config.string("SUPABASE_HOME")); /** * Serializes the state like Go's `json.Marshal` of `State` (`state.go:25-31`): @@ -106,11 +107,15 @@ function parseGoRfc3339Ms(text: string): number | undefined { if (day < 1 || day > daysInMonth(year, month)) return undefined; if (hour > 23 || minute > 59 || second > 59) return undefined; if (match[9] !== undefined && (Number(match[9]) > 24 || Number(match[10]) > 60)) return undefined; - // `setUTCFullYear` (not `Date.UTC`) so years 0000-0099 aren't remapped to - // 1900-1999; components are already range-checked, so no rollover occurs. - const date = new Date(0); - date.setUTCFullYear(year, month - 1, day); - date.setUTCHours(hour, minute, second, 0); + const date = DateTime.makeUnsafe({ + year, + month, + day, + hour, + minute, + second, + millisecond: 0, + }); // Go reads at most 9 fractional digits (nanoseconds); ms precision is // exact for the 30-minute comparison this feeds. const fractionMs = match[7] !== undefined ? Number(`0.${match[7].slice(0, 9)}`) * 1000 : 0; @@ -118,7 +123,7 @@ function parseGoRfc3339Ms(text: string): number | undefined { match[8] !== undefined ? (match[8] === "-" ? -1 : 1) * (Number(match[9]) * 3600 + Number(match[10]) * 60) * 1000 : 0; - return date.getTime() + fractionMs - offsetMs; + return DateTime.toEpochMillis(date) + fractionMs - offsetMs; } function isRecord(value: unknown): value is Record<string, unknown> { @@ -435,48 +440,56 @@ export function readExistingState(text: string): PriorState | undefined { } } +const loadOrCreateLegacyTelemetryStateWithEnv = Effect.fn( + "legacy.telemetry.loadOrCreateStateWithEnv", +)(function* (opts: { readonly now?: Date }, configuredHome: string | undefined) { + const fs = yield* FileSystem.FileSystem; + const pathSvc = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const filePath = legacyTelemetryPath(pathSvc, configuredHome); + const exists = yield* fs.exists(filePath); + const existing = exists ? yield* fs.readFileString(filePath) : undefined; + const prior = existing !== undefined ? readExistingState(existing) : undefined; + const now = opts.now ?? (yield* DateTime.nowAsDate); + const nowIso = now.toISOString(); + + // The expiry comparison uses the epoch computed by `parseGoRfc3339Ms` + // during decode — NOT a `new Date(string)` re-parse. Go-valid forms JS + // cannot parse (comma fraction `…00,5Z`, offsets `+24:00`/`+05:60`) + // would NaN there and read as expired, rotating `session_id` where Go — + // which decoded the instant fine — retains it inside the 30-minute + // window (`LoadOrCreateState`, `state.go:140-148`; verified against the + // Go binary: a recent `…00,5Z` keeps the seeded session id). + const priorActiveMs = prior?.sessionLastActiveMs; + const expired = + priorActiveMs === undefined || now.getTime() - priorActiveMs > SESSION_ROTATION_MS; + + const state: State = { + enabled: prior?.enabled ?? true, + device_id: prior?.device_id ?? (yield* crypto.randomUUIDv4), + session_id: + !expired && prior?.session_id !== undefined ? prior.session_id : yield* crypto.randomUUIDv4, + session_last_active: nowIso, + ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), + // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). + // The numeric field is for in-memory readers; the exact token rides + // along for the write so magnitudes above 2^53 round-trip like Go. + schema_version: + prior?.schemaVersionToken !== undefined ? Number(prior.schemaVersionToken) : SCHEMA_VERSION, + ...(prior?.schemaVersionToken !== undefined + ? { schemaVersionToken: prior.schemaVersionToken } + : {}), + }; + + yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(state)); + return state; +}); + export const loadOrCreateLegacyTelemetryState = Effect.fn("legacy.telemetry.loadOrCreateState")( function* (opts: { readonly now?: Date } = {}) { - const fs = yield* FileSystem.FileSystem; - const pathSvc = yield* Path.Path; - const filePath = legacyTelemetryPath(process.env, pathSvc); - const exists = yield* fs.exists(filePath); - const existing = exists ? yield* fs.readFileString(filePath) : undefined; - const prior = existing !== undefined ? readExistingState(existing) : undefined; - const now = opts.now ?? new Date(); - const nowIso = now.toISOString(); - - // The expiry comparison uses the epoch computed by `parseGoRfc3339Ms` - // during decode — NOT a `new Date(string)` re-parse. Go-valid forms JS - // cannot parse (comma fraction `…00,5Z`, offsets `+24:00`/`+05:60`) - // would NaN there and read as expired, rotating `session_id` where Go — - // which decoded the instant fine — retains it inside the 30-minute - // window (`LoadOrCreateState`, `state.go:140-148`; verified against the - // Go binary: a recent `…00,5Z` keeps the seeded session id). - const priorActiveMs = prior?.sessionLastActiveMs; - const expired = - priorActiveMs === undefined || now.getTime() - priorActiveMs > SESSION_ROTATION_MS; - - const state: State = { - enabled: prior?.enabled ?? true, - device_id: prior?.device_id ?? crypto.randomUUID(), - session_id: - !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), - session_last_active: nowIso, - ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), - // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). - // The numeric field is for in-memory readers; the exact token rides - // along for the write so magnitudes above 2^53 round-trip like Go. - schema_version: - prior?.schemaVersionToken !== undefined ? Number(prior.schemaVersionToken) : SCHEMA_VERSION, - ...(prior?.schemaVersionToken !== undefined - ? { schemaVersionToken: prior.schemaVersionToken } - : {}), - }; - - yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(state)); - return state; + const configuredHome = Option.getOrUndefined(yield* resolveTelemetryHome); + return yield* loadOrCreateLegacyTelemetryStateWithEnv(opts, configuredHome); }, ); @@ -490,7 +503,8 @@ export const setLegacyTelemetryEnabled = Effect.fn("legacy.telemetry.setEnabled" const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; const nextState: State = { ...state, enabled }; - const filePath = legacyTelemetryPath(process.env, pathSvc); + const configuredHome = Option.getOrUndefined(yield* resolveTelemetryHome); + const filePath = legacyTelemetryPath(pathSvc, configuredHome); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(nextState)); return nextState; @@ -505,25 +519,29 @@ export const setLegacyTelemetryEnabled = Effect.fn("legacy.telemetry.setEnabled" */ const persistLegacyDistinctId = Effect.fn("legacy.telemetry.persistDistinctId")(function* ( distinctId: string | undefined, + configuredHome: string | undefined, ) { - const base = yield* loadOrCreateLegacyTelemetryState(); + const base = yield* loadOrCreateLegacyTelemetryStateWithEnv({}, configuredHome); const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; const { distinct_id: _drop, ...rest } = base; const nextState: State = distinctId !== undefined && distinctId.length > 0 ? { ...rest, distinct_id: distinctId } : rest; - const filePath = legacyTelemetryPath(process.env, pathSvc); + const filePath = legacyTelemetryPath(pathSvc, configuredHome); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(nextState)); }); -const persistLegacyIdentityReset = Effect.fn("legacy.telemetry.persistIdentityReset")(function* () { - const base = yield* loadOrCreateLegacyTelemetryState(); +const persistLegacyIdentityReset = Effect.fn("legacy.telemetry.persistIdentityReset")(function* ( + configuredHome: string | undefined, +) { + const base = yield* loadOrCreateLegacyTelemetryStateWithEnv({}, configuredHome); const fs = yield* FileSystem.FileSystem; const pathSvc = yield* Path.Path; + const crypto = yield* Crypto.Crypto; const { distinct_id: _drop, ...rest } = base; - const nextState: State = { ...rest, device_id: crypto.randomUUID() }; - const filePath = legacyTelemetryPath(process.env, pathSvc); + const nextState: State = { ...rest, device_id: yield* crypto.randomUUIDv4 }; + const filePath = legacyTelemetryPath(pathSvc, configuredHome); yield* fs.makeDirectory(pathSvc.dirname(filePath), { recursive: true }); yield* fs.writeFileString(filePath, serializeLegacyTelemetryState(nextState)); }); @@ -552,14 +570,24 @@ export const legacyTelemetryStateLayer = Layer.effect( const analytics = yield* Analytics; const runtime = yield* TelemetryRuntime; - const provide = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => - effect.pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, pathSvc), - ); + const crypto = yield* Crypto.Crypto; + const configuredHome = Option.getOrUndefined(yield* resolveTelemetryHome); + + const services = Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, fs), + Layer.succeed(Path.Path, pathSvc), + Layer.succeed(Crypto.Crypto, crypto), + ); + + const provide = <A, E>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Crypto.Crypto>, + ) => Effect.provide(effect, services); return LegacyTelemetryState.of({ - flush: provide(loadOrCreateLegacyTelemetryState()).pipe(Effect.asVoid, Effect.ignore), + flush: provide(loadOrCreateLegacyTelemetryStateWithEnv({}, configuredHome)).pipe( + Effect.asVoid, + Effect.ignore, + ), stitchLogin: (distinctId: string) => // Mirrors Go's `StitchLogin`: the in-memory stamp always happens so // subsequent captures in this process carry the user's id; the alias @@ -577,18 +605,22 @@ export const legacyTelemetryStateLayer = Layer.effect( if (firstIdentity) { yield* analytics.alias(distinctId, runtime.deviceId).pipe(Effect.ignore); } - yield* provide(persistLegacyDistinctId(distinctId)); + yield* provide(persistLegacyDistinctId(distinctId, configuredHome)); }).pipe(Effect.ignore), clearDistinctId: Effect.sync(() => { runtime.identity.clear(); }).pipe( - Effect.andThen(provide(persistLegacyDistinctId(undefined))), + Effect.andThen(provide(persistLegacyDistinctId(undefined, configuredHome))), Effect.asVoid, Effect.ignore, ), resetIdentity: Effect.sync(() => { runtime.identity.clear(); - }).pipe(Effect.andThen(provide(persistLegacyIdentityReset())), Effect.asVoid, Effect.ignore), + }).pipe( + Effect.andThen(provide(persistLegacyIdentityReset(configuredHome))), + Effect.asVoid, + Effect.ignore, + ), }); }), ); diff --git a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts index aa6f5315f3..151a9f81c8 100644 --- a/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts +++ b/apps/cli/src/legacy/telemetry/legacy-telemetry-state.layer.unit.test.ts @@ -1,13 +1,9 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { BunServices } from "@effect/platform-bun"; +import { BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; -import { afterEach, beforeEach } from "vitest"; +import { ConfigProvider, DateTime, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { mockAnalytics } from "../../../tests/helpers/mocks.ts"; +import { useLegacyTempWorkdir } from "../../../tests/helpers/legacy-mocks.ts"; import { TelemetryRuntime } from "../../shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../shared/telemetry/identity.ts"; import { @@ -17,20 +13,48 @@ import { } from "./legacy-telemetry-state.layer.ts"; import { LegacyTelemetryState } from "./legacy-telemetry-state.service.ts"; -let tempHome: string; -let prevHome: string | undefined; - -beforeEach(() => { - tempHome = mkdtempSync(join(tmpdir(), "supabase-legacy-telemetry-")); - prevHome = process.env["SUPABASE_HOME"]; - process.env["SUPABASE_HOME"] = tempHome; -}); +const temp = useLegacyTempWorkdir("supabase-legacy-telemetry-"); +const testPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunPath.layer))); +const RECENT_DATE = DateTime.toDateUtc(DateTime.makeUnsafe("2025-01-01T00:10:00Z")); +const RECENT_ISO = "2025-01-01T00:00:00.000Z"; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const telemetryPath = () => testPath.join(temp.current, "telemetry.json"); +const testConfigLayer = () => + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { SUPABASE_HOME: temp.current }, + preserveEmptyStrings: true, + }), + ); -afterEach(() => { - if (prevHome === undefined) delete process.env["SUPABASE_HOME"]; - else process.env["SUPABASE_HOME"] = prevHome; - rmSync(tempHome, { recursive: true, force: true }); -}); +const testServices = () => Layer.mergeAll(BunServices.layer, testConfigLayer()); + +const writeState = (text: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(telemetryPath()), { recursive: true }); + yield* fs.writeFileString(telemetryPath(), text); + }).pipe(Effect.provide(BunServices.layer)); + +const readFileText = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(telemetryPath()); + }).pipe(Effect.provide(BunServices.layer)); + +const readState = () => + Effect.gen(function* () { + const text = yield* readFileText(); + return decodeJson(text) as Record<string, unknown>; + }).pipe(Effect.provide(BunServices.layer)); + +const stateExists = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(telemetryPath()); + }).pipe(Effect.provide(BunServices.layer)); function makeRuntime(opts: { isCi?: boolean; isFirstRun?: boolean; isTty?: boolean } = {}) { const identity = makeTelemetryIdentity(undefined); @@ -58,22 +82,19 @@ function makeLayer( ) { return legacyTelemetryStateLayer.pipe( Layer.provide(BunServices.layer), + Layer.provide(testConfigLayer()), Layer.provide(analytics.layer), Layer.provide(runtime.layer), ); } -const telemetryPath = () => join(tempHome, "telemetry.json"); -const readState = (): Record<string, unknown> => - JSON.parse(readFileSync(telemetryPath(), "utf8")) as Record<string, unknown>; const seedState = (distinctId?: string) => - writeFileSync( - telemetryPath(), - JSON.stringify({ + writeState( + encodeJson({ enabled: true, device_id: "device-xyz", session_id: "session-1", - session_last_active: new Date().toISOString(), + session_last_active: "2025-01-01T00:00:00Z", ...(distinctId !== undefined ? { distinct_id: distinctId } : {}), schema_version: 1, }), @@ -87,7 +108,7 @@ describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { const state = yield* LegacyTelemetryState; yield* state.stitchLogin("gotrue-1"); expect(analytics.aliased).toEqual([{ distinctId: "gotrue-1", alias: "device-xyz" }]); - expect(readState().distinct_id).toBe("gotrue-1"); + expect((yield* readState()).distinct_id).toBe("gotrue-1"); expect(runtime.identity.current()).toBe("gotrue-1"); }).pipe(Effect.provide(makeLayer(analytics, runtime))); }); @@ -101,7 +122,7 @@ describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { const state = yield* LegacyTelemetryState; yield* state.stitchLogin("gotrue-ci"); expect(analytics.aliased).toEqual([]); - expect(existsSync(telemetryPath())).toBe(false); + expect(yield* stateExists()).toBe(false); expect(runtime.identity.current()).toBe("gotrue-ci"); }).pipe(Effect.provide(makeLayer(analytics, runtime))); }, @@ -114,44 +135,44 @@ describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { const state = yield* LegacyTelemetryState; yield* state.stitchLogin("gotrue-npx"); expect(analytics.aliased).toEqual([]); - expect(existsSync(telemetryPath())).toBe(false); + expect(yield* stateExists()).toBe(false); expect(runtime.identity.current()).toBe("gotrue-npx"); }).pipe(Effect.provide(makeLayer(analytics, runtime))); }); it.effect("stitchLogin replaces a stale distinct_id (parity: stale id is replaced)", () => { - seedState("stale-id"); const analytics = mockAnalytics(); return Effect.gen(function* () { + yield* seedState("stale-id"); const state = yield* LegacyTelemetryState; yield* state.stitchLogin("fresh-id"); - expect(readState().distinct_id).toBe("fresh-id"); + expect((yield* readState()).distinct_id).toBe("fresh-id"); }).pipe(Effect.provide(makeLayer(analytics))); }); it.effect("stitchLogin with an existing identity persists and stamps without re-aliasing", () => { - seedState("user-a"); const analytics = mockAnalytics(); const runtime = makeRuntime(); runtime.identity.stamp("user-a"); return Effect.gen(function* () { + yield* seedState("user-a"); const state = yield* LegacyTelemetryState; yield* state.stitchLogin("user-b"); expect(analytics.aliased).toEqual([]); - expect(readState().distinct_id).toBe("user-b"); + expect((yield* readState()).distinct_id).toBe("user-b"); expect(runtime.identity.current()).toBe("user-b"); }).pipe(Effect.provide(makeLayer(analytics, runtime))); }); it.effect("resetIdentity rotates the device id and forgets the user", () => { - seedState("user-a"); const analytics = mockAnalytics(); const runtime = makeRuntime(); runtime.identity.stamp("user-a"); return Effect.gen(function* () { + yield* seedState("user-a"); const state = yield* LegacyTelemetryState; yield* state.resetIdentity; - const next = readState(); + const next = yield* readState(); expect(next.distinct_id).toBeUndefined(); expect(next.device_id).not.toBe("device-xyz"); expect(runtime.identity.current()).toBeUndefined(); @@ -161,14 +182,14 @@ describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { it.effect( "clearDistinctId removes the persisted distinct_id and empties the in-process identity", () => { - seedState("to-clear"); const analytics = mockAnalytics(); const runtime = makeRuntime(); runtime.identity.stamp("to-clear"); return Effect.gen(function* () { + yield* seedState("to-clear"); const state = yield* LegacyTelemetryState; yield* state.clearDistinctId; - expect(readState().distinct_id).toBeUndefined(); + expect((yield* readState()).distinct_id).toBeUndefined(); expect(runtime.identity.current()).toBeUndefined(); }).pipe(Effect.provide(makeLayer(analytics, runtime))); }, @@ -181,13 +202,14 @@ describe("legacyTelemetryStateLayer.stitchLogin / clearDistinctId", () => { describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothing recovery)", () => { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; - const runLoad = () => loadOrCreateLegacyTelemetryState().pipe(Effect.provide(BunServices.layer)); + const runLoad = () => + loadOrCreateLegacyTelemetryState({ now: RECENT_DATE }).pipe(Effect.provide(testServices())); const runLoadAt = (now: Date) => - loadOrCreateLegacyTelemetryState({ now }).pipe(Effect.provide(BunServices.layer)); + loadOrCreateLegacyTelemetryState({ now }).pipe(Effect.provide(testServices())); it.effect("a bool-only file missing device_id/session_id is wholly regenerated", () => { - writeFileSync(telemetryPath(), JSON.stringify({ enabled: false })); return Effect.gen(function* () { + yield* writeState(encodeJson({ enabled: false })); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).toMatch(UUID_RE); @@ -196,17 +218,16 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("an empty device_id string invalidates an otherwise-valid file", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "", - session_id: "session-1", - session_last_active: new Date().toISOString(), - schema_version: 2, - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "", + session_id: "session-1", + session_last_active: RECENT_ISO, + schema_version: 2, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).toMatch(UUID_RE); @@ -215,17 +236,16 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("a fully valid file with a recent session is preserved verbatim", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: new Date().toISOString(), - schema_version: 2, - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: RECENT_ISO, + schema_version: 2, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -237,16 +257,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect( "the consent form with a unix-millis session_last_active decodes and preserves enabled:false", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - consent: "denied", - device_id: "d", - session_id: "s", - session_last_active: 1750000000000, - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + consent: "denied", + device_id: "d", + session_id: "s", + session_last_active: 1750000000000, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -259,17 +278,16 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // `consent` decides the value (`state.go:35`, `state.go:88-91`): // `"enabled":"invalid"` is an UnmarshalTypeError → errMalformedState → // fresh state with telemetry re-enabled and new identities. - writeFileSync( - telemetryPath(), - JSON.stringify({ - consent: "denied", - enabled: "invalid", - device_id: "d", - session_id: "s", - session_last_active: new Date().toISOString(), - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + consent: "denied", + enabled: "invalid", + device_id: "d", + session_id: "s", + session_last_active: RECENT_ISO, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -281,17 +299,16 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // JSON `null` unmarshals cleanly into Go's `Enabled *bool` (nil pointer, // no error) and `parseConsent` then honors the consent value — only // non-boolean, non-null types invalidate the file. - writeFileSync( - telemetryPath(), - JSON.stringify({ - consent: "denied", - enabled: null, - device_id: "d", - session_id: "s", - session_last_active: new Date().toISOString(), - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + consent: "denied", + enabled: null, + device_id: "d", + session_id: "s", + session_last_active: RECENT_ISO, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -300,16 +317,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("an unrecognized consent value is malformed and is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - consent: "maybe", - device_id: "d", - session_id: "s", - session_last_active: new Date().toISOString(), - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + consent: "maybe", + device_id: "d", + session_id: "s", + session_last_active: RECENT_ISO, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -324,16 +340,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect( "a calendar-invalid session_last_active (Feb 29, non-leap year) is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-02-29T00:00:00Z", - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-02-29T00:00:00Z", + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).toMatch(UUID_RE); @@ -343,16 +358,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin ); it.effect("a valid leap-day session_last_active decodes and preserves the state", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2024-02-29T00:00:00Z", - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2024-02-29T00:00:00Z", + }), + ); const state = yield* runLoad(); // The timestamp is long-stale so the session rotates, but the file // decoded: enabled/device_id are preserved, exactly like Go. @@ -362,16 +376,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("an out-of-range hour (T24) in session_last_active is wholly regenerated", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T24:00:00Z", - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T24:00:00Z", + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -385,16 +398,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // `+24:00` is a VALID Go timestamp — regenerating here (as a plain // `Date.parse` validity check would) would wrongly reset `enabled` and // rotate the device identity. - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T00:00:00+24:00", - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00+24:00", + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -407,16 +419,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // before fractional seconds (`commaOrPeriod`, `time/format.go`; verified // against go1.26). Classifying this as malformed would regenerate the // file with telemetry re-enabled and fresh identities — Go preserves it. - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T00:00:00,123Z", - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00,123Z", + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -426,16 +437,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("a comma with no fractional digits is malformed and is wholly regenerated", () => { // Go rejects `…T00:00:00,Z` ("cannot parse \",Z\" as \"Z07:00\"") — the // separator only participates when at least one digit follows. - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T00:00:00,Z", - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00,Z", + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -449,17 +459,18 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // seeding `<now>,5Z` and running `supabase-go telemetry status` keeps the // seeded session id; the TS CLI before this fix rotated it). it.effect("a recent comma-fraction timestamp keeps the session id within 30 minutes", () => { - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T00:00:00,5Z", - }), - ); return Effect.gen(function* () { - const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00,5Z", + }), + ); + const state = yield* runLoadAt( + DateTime.toDateUtc(DateTime.makeUnsafe("2025-01-01T00:10:00Z")), + ); expect(state.session_id).toBe("s"); expect(state.device_id).toBe("d"); expect(state.enabled).toBe(false); @@ -470,17 +481,18 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // `+05:60` normalizes to a 6-hour offset in Go, so this instant is // 2025-01-01T00:00:00Z — 10 minutes before `now` → session retained. // (JS `new Date` returns NaN for minute-60 offsets, which would rotate.) - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T06:00:00+05:60", - }), - ); return Effect.gen(function* () { - const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T06:00:00+05:60", + }), + ); + const state = yield* runLoadAt( + DateTime.toDateUtc(DateTime.makeUnsafe("2025-01-01T00:10:00Z")), + ); expect(state.session_id).toBe("s"); expect(state.device_id).toBe("d"); }); @@ -491,17 +503,18 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // `now` = 2025-01-01T00:10:00Z the session is 24h10m stale → Go rotates. // Reading the wall clock as UTC (ignoring the offset) would wrongly // retain it. The decoded file is still preserved (enabled/device_id). - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: "2025-01-01T00:00:00+24:00", - }), - ); return Effect.gen(function* () { - const state = yield* runLoadAt(new Date("2025-01-01T00:10:00Z")); + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: "2025-01-01T00:00:00+24:00", + }), + ); + const state = yield* runLoadAt( + DateTime.toDateUtc(DateTime.makeUnsafe("2025-01-01T00:10:00Z")), + ); expect(state.session_id).not.toBe("s"); expect(state.device_id).toBe("d"); expect(state.enabled).toBe(false); @@ -514,16 +527,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // never expired, session retained. Kept as a plain number here so the // comparison behaves identically (a `Date`/`toISOString` round-trip // throws beyond ±8.64e15 and used to regenerate the whole file). - writeFileSync( - telemetryPath(), - JSON.stringify({ - consent: "denied", - device_id: "d", - session_id: "s", - session_last_active: 9_000_000_000_000_000, - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + consent: "denied", + device_id: "d", + session_id: "s", + session_last_active: 9_000_000_000_000_000, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -536,16 +548,15 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // outright (any float/exponent token is an UnmarshalTypeError for int64) // → `errMalformedState` → wholesale regeneration: telemetry re-enabled, // fresh identities — even though the file said "denied". - writeFileSync( - telemetryPath(), - JSON.stringify({ - consent: "denied", - device_id: "d", - session_id: "s", - session_last_active: 1e100, - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + consent: "denied", + device_id: "d", + session_id: "s", + session_last_active: 1e100, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -559,11 +570,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // different literal). The raw-token check accepts it via exact BigInt // bounds — the parsed double rounds to 2^63 and could not distinguish it // from Go-invalid 9223372036854775808 (see the companion test below). - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775807}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775807}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -577,11 +587,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // far past, so — exactly like Go — the file DECODES (enabled/device_id // preserved, no wholesale regeneration) while the >30-minute-stale // session id rotates. - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":-9223372036854775808}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":-9223372036854775808}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -596,11 +605,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // would preserve `consent: "denied"` and the identities where Go // regenerates a fresh telemetry-enabled state. it.effect("consent-form unix millis written as an exponent token regenerate like Go", () => { - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1e3}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1e3}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -611,11 +619,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect( "consent-form unix millis written as an integer-valued float regenerate like Go", () => { - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000.0}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000.0}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -628,11 +635,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // 9223372036854775808 parses to the SAME double as Go's max valid literal // 9223372036854775807 (both round to 2^63), so only the raw token can // tell them apart — Go rejects this one with an UnmarshalTypeError. - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775808}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775808}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -644,11 +650,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // The raw-token capture is scoped to the ROOT object by holder identity. // Go ignores unknown fields entirely, so a nested `session_last_active` // must neither shadow nor invalidate the valid top-level millis. - writeFileSync( - telemetryPath(), - '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000,"extra":{"session_last_active":1.5}}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000,"extra":{"session_last_active":1.5}}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -659,11 +664,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // `SchemaVersion int` sits in the single-shot unmarshal (`state.go:41`), // where the token `1.0` is an UnmarshalTypeError → the WHOLE file is // malformed and regenerated, even though `JSON.parse` reads it as 1. - writeFileSync( - telemetryPath(), - '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":1.0}', - ); return Effect.gen(function* () { + yield* writeState( + '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":1.0}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -675,17 +679,16 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // `SchemaVersion int` sits in the same single-shot unmarshal // (`state.go:41`, `state.go:88-90`): an overflowing value malforms the // whole file, not just the field. - writeFileSync( - telemetryPath(), - JSON.stringify({ - enabled: false, - device_id: "d", - session_id: "s", - session_last_active: new Date().toISOString(), - schema_version: 1e100, - }), - ); return Effect.gen(function* () { + yield* writeState( + encodeJson({ + enabled: false, + device_id: "d", + session_id: "s", + session_last_active: RECENT_ISO, + schema_version: 1e100, + }), + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -703,11 +706,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("a wrong-typed earlier consent regenerates even when the final one is valid", () => { // Go: `cannot unmarshal bool into … rawState.consent of type string` — // the file must NOT stay disabled off the surviving `"denied"`. - writeFileSync( - telemetryPath(), - '{"consent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -715,11 +717,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("a wrong-typed FINAL consent regenerates too", () => { - writeFileSync( - telemetryPath(), - '{"consent":"denied","consent":false,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","consent":false,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -727,11 +728,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("well-typed duplicate enabled decodes cleanly with last-value-wins", () => { - writeFileSync( - telemetryPath(), - `{"enabled":true,"enabled":false,"session_last_active":${JSON.stringify(new Date().toISOString())},"device_id":"d","session_id":"s"}`, - ); return Effect.gen(function* () { + yield* writeState( + `{"enabled":true,"enabled":false,"session_last_active":${encodeJson(RECENT_ISO)},"device_id":"d","session_id":"s"}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -742,11 +742,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("a non-integer earlier schema_version token regenerates like Go", () => { // `1e3` into `SchemaVersion int` is an UnmarshalTypeError on the first // occurrence; the valid `2` after it cannot save the file. - writeFileSync( - telemetryPath(), - '{"consent":"granted","session_last_active":1750000000000,"device_id":"d","session_id":"s","schema_version":1e3,"schema_version":2}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"granted","session_last_active":1750000000000,"device_id":"d","session_id":"s","schema_version":1e3,"schema_version":2}', + ); const state = yield* runLoad(); expect(state.device_id).not.toBe("d"); expect(state.schema_version).toBe(1); @@ -756,11 +755,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("duplicate session_last_active takes the last token (json.RawMessage)", () => { // The RawMessage field is never type-checked per occurrence — only the // FINAL token is parsed (`state.go:69-85`), so junk before it is fine. - writeFileSync( - telemetryPath(), - '{"consent":"denied","session_last_active":true,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); return Effect.gen(function* () { + yield* writeState( + '{"consent":"denied","session_last_active":true,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -770,11 +768,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect( "a wrong-typed earlier device_id regenerates even when the final one is valid", () => { - writeFileSync( - telemetryPath(), - `{"enabled":false,"device_id":0,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); return Effect.gen(function* () { + yield* writeState( + `{"enabled":false,"device_id":0,"device_id":"d","session_id":"s","session_last_active":${encodeJson(RECENT_ISO)}}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -785,11 +782,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("null occurrences are decode-valid for pointer and string fields alike", () => { // `null` → nil for `Enabled *bool` (later duplicate overwrites) and a // no-op for `DeviceID string` — no UnmarshalTypeError anywhere. - writeFileSync( - telemetryPath(), - `{"enabled":null,"enabled":false,"device_id":null,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); return Effect.gen(function* () { + yield* writeState( + `{"enabled":null,"enabled":false,"device_id":null,"device_id":"d","session_id":"s","session_last_active":${encodeJson(RECENT_ISO)}}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -802,11 +798,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin // string leaves the previous occurrence's value in place, where // `JSON.parse`'s last-value-wins would surface `null` and wrongly // regenerate. - writeFileSync( - telemetryPath(), - `{"enabled":false,"device_id":"d","device_id":null,"session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); return Effect.gen(function* () { + yield* writeState( + `{"enabled":false,"device_id":"d","device_id":null,"session_id":"s","session_last_active":${encodeJson(RECENT_ISO)}}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -815,11 +810,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); it.effect("a null FINAL schema_version keeps the earlier non-zero value", () => { - writeFileSync( - telemetryPath(), - `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())},"schema_version":7,"schema_version":null}`, - ); return Effect.gen(function* () { + yield* writeState( + `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${encodeJson(RECENT_ISO)},"schema_version":7,"schema_version":null}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.schema_version).toBe(7); @@ -828,11 +822,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("wrong-typed duplicates of UNKNOWN keys never invalidate the file", () => { // Go skips unknown fields untyped — no occurrence of `junk` can error. - writeFileSync( - telemetryPath(), - `{"enabled":false,"junk":false,"junk":"x","device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, - ); return Effect.gen(function* () { + yield* writeState( + `{"enabled":false,"junk":false,"junk":"x","device_id":"d","session_id":"s","session_last_active":${encodeJson(RECENT_ISO)}}`, + ); const state = yield* runLoad(); expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); @@ -842,11 +835,10 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin it.effect("an escaped duplicate key is unescaped before field matching, like Go", () => { // encoding/json unescapes key tokens before struct-field matching, so // `"consent":false` is a wrong-typed `consent` occurrence. - writeFileSync( - telemetryPath(), - '{"\\u0063onsent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', - ); return Effect.gen(function* () { + yield* writeState( + '{"\\u0063onsent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', + ); const state = yield* runLoad(); expect(state.enabled).toBe(true); expect(state.device_id).not.toBe("d"); @@ -856,53 +848,54 @@ describe("loadOrCreateLegacyTelemetryState (Go decodeState parity: all-or-nothin }); describe("exact int64 schema_version round-trip (Go json.Marshal parity)", () => { - const runLoad = () => loadOrCreateLegacyTelemetryState().pipe(Effect.provide(BunServices.layer)); + const runLoad = () => + loadOrCreateLegacyTelemetryState({ now: RECENT_DATE }).pipe(Effect.provide(testServices())); - // File contents are hand-built strings: `JSON.stringify(9007199254740993)` + // File contents are hand-built strings: `encodeJson(9007199254740993)` // would round inside the test itself, hiding exactly the bug under test. const fileWith = (schemaVersionToken: string): string => - `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify( - new Date().toISOString(), + `{"enabled":false,"device_id":"d","session_id":"s","session_last_active":${encodeJson( + RECENT_ISO, )},"schema_version":${schemaVersionToken}}`; it.effect("a valid schema_version above 2^53 is persisted verbatim, like Go's int64", () => { // Go decodes 9007199254740993 into `SchemaVersion int` exactly and // `json.Marshal` re-emits it verbatim; a `Number` round-trip persists the // rounded …992 (review r3683813242). - writeFileSync(telemetryPath(), fileWith("9007199254740993")); return Effect.gen(function* () { + yield* writeState(fileWith("9007199254740993")); yield* runLoad(); - const written = readFileSync(telemetryPath(), "utf8"); + const written = yield* readFileText(); expect(written).toContain('"schema_version":9007199254740993'); expect(written).not.toContain("9007199254740992"); }); }); it.effect("the int64 maximum round-trips exactly", () => { - writeFileSync(telemetryPath(), fileWith("9223372036854775807")); return Effect.gen(function* () { + yield* writeState(fileWith("9223372036854775807")); yield* runLoad(); - const written = readFileSync(telemetryPath(), "utf8"); + const written = yield* readFileText(); expect(written).toContain('"schema_version":9223372036854775807'); }); }); it.effect("setLegacyTelemetryEnabled's rewrite also preserves the exact token", () => { - writeFileSync(telemetryPath(), fileWith("9007199254740993")); return Effect.gen(function* () { - yield* setLegacyTelemetryEnabled(true).pipe(Effect.provide(BunServices.layer)); - const written = readFileSync(telemetryPath(), "utf8"); + yield* writeState(fileWith("9007199254740993")); + yield* setLegacyTelemetryEnabled(true).pipe(Effect.provide(testServices())); + const written = yield* readFileText(); expect(written).toContain('"enabled":true'); expect(written).toContain('"schema_version":9007199254740993'); }); }); it.effect("a zero schema_version still falls back to the current constant, like Go", () => { - writeFileSync(telemetryPath(), fileWith("0")); return Effect.gen(function* () { + yield* writeState(fileWith("0")); const state = yield* runLoad(); expect(state.schema_version).toBe(1); - const written = readFileSync(telemetryPath(), "utf8"); + const written = yield* readFileText(); expect(written).toContain('"schema_version":1'); }); }); diff --git a/apps/cli/src/next/auth/credentials.layer.ts b/apps/cli/src/next/auth/credentials.layer.ts index 1f29ad54ca..bef99a9cfd 100644 --- a/apps/cli/src/next/auth/credentials.layer.ts +++ b/apps/cli/src/next/auth/credentials.layer.ts @@ -8,6 +8,38 @@ const SERVICE = "Supabase CLI"; const ACCOUNT = "access-token"; const LEGACY_ACCOUNT = "supabase"; +type KeyringModule = typeof import("@napi-rs/keyring"); + +const readKeyringToken = (keyring: KeyringModule, account: string) => + Effect.try({ + try: () => { + const entry = new keyring.Entry(SERVICE, account); + const token = entry.getPassword(); + return token ? Option.some(Redacted.make(normalizeKeyringToken(token))) : Option.none(); + }, + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => Option.none())); + +const writeKeyringToken = (keyring: KeyringModule, account: string, token: string) => + Effect.try({ + try: () => { + const entry = new keyring.Entry(SERVICE, account); + entry.setPassword(token); + }, + catch: () => undefined, + }).pipe(Effect.option); + +const deleteKeyringToken = (keyring: KeyringModule, account: string) => + Effect.try({ + try: () => { + const entry = new keyring.Entry(SERVICE, account); + if (!entry.getPassword()) return false; + entry.deleteCredential(); + return true; + }, + catch: () => undefined, + }).pipe(Effect.orElseSucceed(() => false)); + /** * credentialsLayer - Token persistence policy for the CLI. * @@ -30,21 +62,10 @@ const makeCredentials = Effect.gen(function* () { // Read current storage first, then fall back to legacy account and finally the filesystem. getAccessToken: Effect.gen(function* () { if (Option.isSome(keyringModule)) { - try { - const entry = new keyringModule.value.Entry(SERVICE, ACCOUNT); - const token = entry.getPassword(); - if (token) return Option.some(Redacted.make(normalizeKeyringToken(token))); - } catch { - /* fall through */ - } - - try { - const entry = new keyringModule.value.Entry(SERVICE, LEGACY_ACCOUNT); - const token = entry.getPassword(); - if (token) return Option.some(Redacted.make(normalizeKeyringToken(token))); - } catch { - /* fall through */ - } + const current = yield* readKeyringToken(keyringModule.value, ACCOUNT); + if (Option.isSome(current)) return current; + const legacy = yield* readKeyringToken(keyringModule.value, LEGACY_ACCOUNT); + if (Option.isSome(legacy)) return legacy; } const exists = yield* fs.exists(fallbackPath); @@ -62,13 +83,8 @@ const makeCredentials = Effect.gen(function* () { Effect.gen(function* () { const plainToken = typeof token === "string" ? token : Redacted.value(token); if (Option.isSome(keyringModule)) { - try { - const entry = new keyringModule.value.Entry(SERVICE, ACCOUNT); - entry.setPassword(plainToken); - return; - } catch { - /* fall through */ - } + const saved = yield* writeKeyringToken(keyringModule.value, ACCOUNT, plainToken); + if (Option.isSome(saved)) return; } yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o700 }); @@ -81,15 +97,8 @@ const makeCredentials = Effect.gen(function* () { if (Option.isSome(keyringModule)) { for (const account of [ACCOUNT, LEGACY_ACCOUNT]) { - try { - const entry = new keyringModule.value.Entry(SERVICE, account); - if (entry.getPassword()) { - entry.deleteCredential(); - anyDeleted = true; - } - } catch { - /* not stored here — fall through */ - } + const deleted = yield* deleteKeyringToken(keyringModule.value, account); + anyDeleted ||= deleted; } } diff --git a/apps/cli/src/next/auth/credentials.layer.unit.test.ts b/apps/cli/src/next/auth/credentials.layer.unit.test.ts index ad62f6cf59..633d58f21c 100644 --- a/apps/cli/src/next/auth/credentials.layer.unit.test.ts +++ b/apps/cli/src/next/auth/credentials.layer.unit.test.ts @@ -1,11 +1,8 @@ -import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { afterEach, beforeEach, vi } from "vitest"; -import { Effect, FileSystem, Layer, Option, Redacted } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path, PlatformError, Redacted } from "effect"; +import { beforeEach, vi } from "vitest"; + import { mockProjectContext, mockRuntimeInfo, @@ -67,17 +64,39 @@ vi.mock("@napi-rs/keyring", () => ({ function makeLayer(home: string, env: Record<string, string> = {}) { const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home }); const projectContextLayer = mockProjectContext(); + const envLayer = processEnvLayer({ HOME: home, ...env }); + const configuredCliConfigLayer = cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provide(envLayer), + Layer.provideMerge(BunServices.layer), + ); const baseLayer = Layer.mergeAll( BunServices.layer, runtimeInfoLayer, projectContextLayer, - processEnvLayer({ HOME: home, ...env }), - cliConfigLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer)), + envLayer, + configuredCliConfigLayer, ); return credentialsLayer.pipe(Layer.provide(baseLayer)); } -let tempHome: string; +const withTempHome = <A, E>( + body: ( + home: string, + fs: FileSystem.FileSystem, + path: Path.Path, + ) => Effect.Effect<A, E, Credentials>, + env: Record<string, string> = {}, +) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-creds-test-" }); + return yield* body(home, fs, path).pipe(Effect.orDie, Effect.provide(makeLayer(home, env))); + }).pipe(Effect.provide(BunServices.layer)), + ); beforeEach(() => { passwords.clear(); @@ -85,11 +104,6 @@ beforeEach(() => { throwOnGetPasswordAccounts.clear(); returnNullForAccounts.clear(); throwOnDeletePasswordAccounts.clear(); - tempHome = mkdtempSync(join(tmpdir(), "supabase-creds-test-")); -}); - -afterEach(() => { - rmSync(tempHome, { recursive: true, force: true }); }); describe("Credentials", () => { @@ -103,252 +117,332 @@ describe("Credentials", () => { describe("getAccessToken", () => { it.effect("reads from current account", () => { passwords.set("Supabase CLI/access-token", "current-token"); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "current-token"); + }), + ); }); it.effect("decodes Go keyring base64 values from current account", () => { passwords.set("Supabase CLI/access-token", encodeGoKeyringBase64("current-token")); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "current-token"); + }), + ); }); it.effect("falls back to legacy account when current is missing", () => { passwords.set("Supabase CLI/supabase", "legacy-token"); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expectSomeToken(token, "legacy-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "legacy-token"); + }), + ); }); it.effect("prefers current account over legacy", () => { passwords.set("Supabase CLI/access-token", "current-token"); passwords.set("Supabase CLI/supabase", "legacy-token"); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expectSomeToken(token, "current-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "current-token"); + }), + ); }); - it.effect("returns none when no token found anywhere", () => { - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); - }); + it.effect("returns none when no token found anywhere", () => + withTempHome(() => + Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + }), + ), + ); it.effect("falls back to filesystem when keyring throws", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token-123", { mode: 0o600 }); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expectSomeToken(token, "fs-token-123"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), "fs-token-123", { + mode: 0o600, + }); + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "fs-token-123"); + }), + ); }); - it.effect("returns Some from filesystem in no-keyring mode", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-only-token", { mode: 0o600 }); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expectSomeToken(token, "fs-only-token"); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); - }); + it.effect("returns Some from filesystem in no-keyring mode", () => + withTempHome( + (home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), "fs-only-token", { + mode: 0o600, + }); + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "fs-only-token"); + }), + { SUPABASE_NO_KEYRING: "1" }, + ), + ); it.effect("returns None when filesystem file is empty", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "", { mode: 0o600 }); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), "", { mode: 0o600 }); + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + }), + ); }); it.effect("returns None when filesystem file has only whitespace", () => { throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), " \n \t ", { mode: 0o600 }); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), " \n \t ", { + mode: 0o600, + }); + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + }), + ); }); it.effect("falls through when keyring returns null for both accounts", () => { returnNullForAccounts.add("Supabase CLI/access-token"); returnNullForAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-fallback-token", { mode: 0o600 }); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - // keyring returns null (falsy) for both → falls through to filesystem - expectSomeToken(token, "fs-fallback-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), "fs-fallback-token", { + mode: 0o600, + }); + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expectSomeToken(token, "fs-fallback-token"); + }), + ); }); - it.effect( - "returns None when filesystem check fails unexpectedly (orElseSucceed branch)", - () => { - throwOnGetPasswordAccounts.add("Supabase CLI/access-token"); - throwOnGetPasswordAccounts.add("Supabase CLI/supabase"); - const failingFs = Layer.succeed(FileSystem.FileSystem, { - exists: (_path: string) => Effect.fail(new Error("permission denied") as any), - readFileString: (_path: string) => Effect.fail(new Error("permission denied") as any), - } as any); - const runtimeInfoLayer = mockRuntimeInfo({ homeDir: tempHome }); - const projectContextLayer = mockProjectContext(); - const layer = credentialsLayer.pipe( - Layer.provide( - Layer.mergeAll( - failingFs, - BunServices.layer, - runtimeInfoLayer, - projectContextLayer, - processEnvLayer({ HOME: tempHome }), - cliConfigLayer.pipe( - Layer.provide(runtimeInfoLayer), - Layer.provide(projectContextLayer), + it.effect("returns None when filesystem check fails unexpectedly (orElseSucceed branch)", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-creds-test-" }); + const failingFs = Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + description: "permission denied", + pathOrDescriptor: path, + }), + ), + readFileString: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + description: "permission denied", + pathOrDescriptor: path, + }), + ), + }), + ); + const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home }); + const projectContextLayer = mockProjectContext(); + const layer = credentialsLayer.pipe( + Layer.provide( + Layer.mergeAll( + failingFs, + BunServices.layer, + runtimeInfoLayer, + projectContextLayer, + processEnvLayer({ HOME: home }), + cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provideMerge(BunServices.layer), + ), ), ), - ), - ); - return Effect.gen(function* () { - const { getAccessToken } = yield* Credentials; - const token = yield* getAccessToken; - expect(token).toEqual(Option.none()); - }).pipe(Effect.provide(layer)); - }, + ); + yield* Effect.gen(function* () { + const { getAccessToken } = yield* Credentials; + const token = yield* getAccessToken; + expect(token).toEqual(Option.none()); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)), + ), ); }); describe("saveAccessToken", () => { - it.effect("saves to keyring when available", () => { - return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("new-token"); - expect(passwords.get("Supabase CLI/access-token")).toBe("new-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); - }); + it.effect("saves to keyring when available", () => + withTempHome(() => + Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("new-token"); + expect(passwords.get("Supabase CLI/access-token")).toBe("new-token"); + }), + ), + ); it.effect("falls back to filesystem when setPassword throws", () => { throwOnSetPassword = true; - return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("fallback-token"); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); - expect(content).toBe("fallback-token"); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("fallback-token"); + const content = yield* fs.readFileString(path.join(home, ".supabase", "access-token")); + expect(content).toBe("fallback-token"); + }), + ); }); - it.effect("saves to filesystem in no-keyring mode", () => { - return Effect.gen(function* () { - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("no-keyring-token"); - const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); - expect(content).toBe("no-keyring-token"); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); - }); + it.effect("saves to filesystem in no-keyring mode", () => + withTempHome( + (home, fs, path) => + Effect.gen(function* () { + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("no-keyring-token"); + const content = yield* fs.readFileString(path.join(home, ".supabase", "access-token")); + expect(content).toBe("no-keyring-token"); + }), + { SUPABASE_NO_KEYRING: "1" }, + ), + ); it.effect("creates .supabase directory if missing", () => { throwOnSetPassword = true; - return Effect.gen(function* () { - expect(existsSync(join(tempHome, ".supabase"))).toBe(false); - const { saveAccessToken } = yield* Credentials; - yield* saveAccessToken("create-dir-token"); - expect(existsSync(join(tempHome, ".supabase"))).toBe(true); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + expect(yield* fs.exists(path.join(home, ".supabase"))).toBe(false); + const { saveAccessToken } = yield* Credentials; + yield* saveAccessToken("create-dir-token"); + expect(yield* fs.exists(path.join(home, ".supabase"))).toBe(true); + }), + ); }); }); describe("deleteAccessToken", () => { - it.effect("returns false when no token exists anywhere", () => { - return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; - expect(deleted).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); - }); + it.effect("returns false when no token exists anywhere", () => + withTempHome(() => + Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + const deleted = yield* deleteAccessToken; + expect(deleted).toBe(false); + }), + ), + ); it.effect("deletes current keyring account and returns true", () => { passwords.set("Supabase CLI/access-token", "my-token"); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; - expect(deleted).toBe(true); - expect(passwords.has("Supabase CLI/access-token")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + const deleted = yield* deleteAccessToken; + expect(deleted).toBe(true); + expect(passwords.has("Supabase CLI/access-token")).toBe(false); + }), + ); }); it.effect("deletes legacy keyring account when current is absent", () => { passwords.set("Supabase CLI/supabase", "legacy-token"); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; - expect(deleted).toBe(true); - expect(passwords.has("Supabase CLI/supabase")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + const deleted = yield* deleteAccessToken; + expect(deleted).toBe(true); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + }), + ); }); it.effect("deletes both keyring accounts when both exist", () => { passwords.set("Supabase CLI/access-token", "current-token"); passwords.set("Supabase CLI/supabase", "legacy-token"); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; - expect(deleted).toBe(true); - expect(passwords.has("Supabase CLI/access-token")).toBe(false); - expect(passwords.has("Supabase CLI/supabase")).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome(() => + Effect.gen(function* () { + const { deleteAccessToken } = yield* Credentials; + const deleted = yield* deleteAccessToken; + expect(deleted).toBe(true); + expect(passwords.has("Supabase CLI/access-token")).toBe(false); + expect(passwords.has("Supabase CLI/supabase")).toBe(false); + }), + ); }); it.effect("deletes filesystem token and returns true", () => { throwOnDeletePasswordAccounts.add("Supabase CLI/access-token"); throwOnDeletePasswordAccounts.add("Supabase CLI/supabase"); - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token", { mode: 0o600 }); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; - expect(deleted).toBe(true); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome))); + return withTempHome((home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), "fs-token", { + mode: 0o600, + }); + const { deleteAccessToken } = yield* Credentials; + const deleted = yield* deleteAccessToken; + expect(deleted).toBe(true); + expect(yield* fs.exists(path.join(supaDir, "access-token"))).toBe(false); + }), + ); }); - it.effect("deletes filesystem token in no-keyring mode", () => { - const supaDir = join(tempHome, ".supabase"); - mkdirSync(supaDir, { recursive: true }); - writeFileSync(join(supaDir, "access-token"), "fs-token", { mode: 0o600 }); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* Credentials; - const deleted = yield* deleteAccessToken; - expect(deleted).toBe(true); - expect(existsSync(join(supaDir, "access-token"))).toBe(false); - }).pipe(Effect.provide(makeLayer(tempHome, { SUPABASE_NO_KEYRING: "1" }))); - }); + it.effect("deletes filesystem token in no-keyring mode", () => + withTempHome( + (home, fs, path) => + Effect.gen(function* () { + const supaDir = path.join(home, ".supabase"); + yield* fs.makeDirectory(supaDir, { recursive: true }); + yield* fs.writeFileString(path.join(supaDir, "access-token"), "fs-token", { + mode: 0o600, + }); + const { deleteAccessToken } = yield* Credentials; + const deleted = yield* deleteAccessToken; + expect(deleted).toBe(true); + expect(yield* fs.exists(path.join(supaDir, "access-token"))).toBe(false); + }), + { SUPABASE_NO_KEYRING: "1" }, + ), + ); }); }); diff --git a/apps/cli/src/next/auth/crypto.layer.ts b/apps/cli/src/next/auth/crypto.layer.ts index 7bf8e2db6f..38e77cc850 100644 --- a/apps/cli/src/next/auth/crypto.layer.ts +++ b/apps/cli/src/next/auth/crypto.layer.ts @@ -1,9 +1,20 @@ import { Buffer } from "node:buffer"; import { createDecipheriv, createECDH, randomUUID, type ECDH } from "node:crypto"; import { hostname, userInfo } from "node:os"; -import { Effect, Layer } from "effect"; +import { Clock, Data, Effect, Layer } from "effect"; import { Crypto, type EncryptedPayload } from "./crypto.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + +class IdentityResolutionError extends Data.TaggedError("IdentityResolutionError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} export const cryptoLayer = Layer.sync(Crypto, () => Crypto.of({ @@ -13,16 +24,17 @@ export const cryptoLayer = Layer.sync(Crypto, () => return { ecdh, publicKeyHex: ecdh.getPublicKey("hex", "uncompressed") }; }), generateSessionId: Effect.sync(() => randomUUID()), - defaultTokenName: Effect.sync(() => { - const ts = Date.now(); - try { - const user = userInfo().username; - const host = hostname(); - if (user && host) return `cli_${user}@${host}_${ts}`; - } catch { - /* fall through */ - } - return `cli_${ts}`; + defaultTokenName: Effect.gen(function* () { + const ts = yield* Clock.currentTimeMillis; + const identity = yield* Effect.try({ + try: () => { + const user = userInfo().username; + const host = hostname(); + return user && host ? `${user}@${host}` : undefined; + }, + catch: () => new IdentityResolutionError(), + }).pipe(Effect.orElseSucceed(() => undefined)); + return identity ? `cli_${identity}_${ts}` : `cli_${ts}`; }), decryptToken: (ecdh: ECDH, payload: EncryptedPayload) => Effect.sync(() => { diff --git a/apps/cli/src/next/auth/crypto.layer.unit.test.ts b/apps/cli/src/next/auth/crypto.layer.unit.test.ts index caed26c365..31fd249cc8 100644 --- a/apps/cli/src/next/auth/crypto.layer.unit.test.ts +++ b/apps/cli/src/next/auth/crypto.layer.unit.test.ts @@ -2,7 +2,7 @@ import { Buffer } from "node:buffer"; import { describe, expect, it } from "@effect/vitest"; import { createCipheriv, createECDH, randomBytes } from "node:crypto"; import { vi } from "vitest"; -import { Cause, Effect, Exit } from "effect"; +import { Cause, Clock, Effect, Exit } from "effect"; import { Crypto } from "./crypto.service.ts"; import { cryptoLayer } from "./crypto.layer.ts"; @@ -11,17 +11,19 @@ const mockOs = vi.hoisted(() => ({ userInfoReturnEmptyUsername: false, })); -vi.mock("node:os", async (importOriginal) => { - const actual = await importOriginal<typeof import("node:os")>(); - return { - ...actual, - userInfo: (...args: Parameters<typeof actual.userInfo>) => { - if (mockOs.userInfoShouldThrow) throw new Error("userInfo unavailable"); - if (mockOs.userInfoReturnEmptyUsername) return { ...actual.userInfo(...args), username: "" }; - return actual.userInfo(...args); - }, - }; -}); +vi.mock("node:os", () => ({ + hostname: () => "test-host", + userInfo: () => { + if (mockOs.userInfoShouldThrow) throw new Error("userInfo unavailable"); + return { + username: mockOs.userInfoReturnEmptyUsername ? "" : "test-user", + uid: 0, + gid: 0, + shell: "/bin/sh", + homedir: "/tmp", + }; + }, +})); const testLayer = cryptoLayer; @@ -115,11 +117,11 @@ describe("Crypto", () => { }); it.effect("contains a numeric timestamp", () => { - const before = Date.now(); return Effect.gen(function* () { + const before = yield* Clock.currentTimeMillis; const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; - const after = Date.now(); + const after = yield* Clock.currentTimeMillis; // Extract the trailing numeric timestamp from the token name. // Both formats end with _<timestamp>: cli_<ts> or cli_<user>@<host>_<ts> @@ -141,15 +143,14 @@ describe("Crypto", () => { const name = yield* defaultTokenName; // The fallback format is exactly cli_<timestamp> with no @ or host part expect(name).toMatch(/^cli_\d+$/); - }) - .pipe(Effect.provide(testLayer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - mockOs.userInfoShouldThrow = false; - }), - ), - ); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring( + Effect.sync(() => { + mockOs.userInfoShouldThrow = false; + }), + ), + ); }); it.effect("falls back to cli_<ts> when username is empty (if-branch false path)", () => { @@ -159,15 +160,14 @@ describe("Crypto", () => { const name = yield* defaultTokenName; // Empty username makes the if-condition falsy, producing the bare timestamp format expect(name).toMatch(/^cli_\d+$/); - }) - .pipe(Effect.provide(testLayer)) - .pipe( - Effect.ensuring( - Effect.sync(() => { - mockOs.userInfoReturnEmptyUsername = false; - }), - ), - ); + }).pipe( + Effect.provide(testLayer), + Effect.ensuring( + Effect.sync(() => { + mockOs.userInfoReturnEmptyUsername = false; + }), + ), + ); }); }); diff --git a/apps/cli/src/next/auth/platform-api.layer.ts b/apps/cli/src/next/auth/platform-api.layer.ts index 17e6247165..483835fd70 100644 --- a/apps/cli/src/next/auth/platform-api.layer.ts +++ b/apps/cli/src/next/auth/platform-api.layer.ts @@ -21,13 +21,11 @@ export const makePlatformApiServices = Effect.gen(function* () { const token = Option.isSome(configuredToken) ? configuredToken : storedToken; if (Option.isNone(token)) { - return yield* Effect.fail( - new PlatformAuthRequiredError({ - message: "You are not logged in to Supabase.", - detail: "Platform commands require a management API access token.", - suggestion: "Run `supabase login` or set SUPABASE_ACCESS_TOKEN before retrying.", - }), - ); + return yield* new PlatformAuthRequiredError({ + message: "You are not logged in to Supabase.", + detail: "Platform commands require a management API access token.", + suggestion: "Run `supabase login` or set SUPABASE_ACCESS_TOKEN before retrying.", + }); } const config = { diff --git a/apps/cli/src/next/auth/platform-api.layer.unit.test.ts b/apps/cli/src/next/auth/platform-api.layer.unit.test.ts index c3727980a5..a5a6119dee 100644 --- a/apps/cli/src/next/auth/platform-api.layer.unit.test.ts +++ b/apps/cli/src/next/auth/platform-api.layer.unit.test.ts @@ -132,9 +132,7 @@ describe("platformApiLayer", () => { ); return Effect.gen(function* () { - const exit = yield* Effect.gen(function* () { - return yield* PlatformApi; - }).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* PlatformApi.pipe(Effect.provide(layer), Effect.exit); expect(exit._tag).toBe("Failure"); if (exit._tag === "Failure") { expect(String(exit.cause)).toContain("PlatformAuthRequiredError"); @@ -236,20 +234,22 @@ describe("platformApiLayer", () => { ), ); + const testLayer = Layer.mergeAll( + layer, + runtimeLayer, + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["branches", "list"]), + }), + ); + return Effect.gen(function* () { const api = yield* PlatformApi; yield* api.v1.listAllBranches({ ref: "abcdefghijklmnopqrst" }); }).pipe( withCommandInstrumentation(), - Effect.provide(layer), - Effect.provide(runtimeLayer), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["branches", "list"]), - }), - ), + Effect.provide(testLayer), Effect.tap(() => Effect.sync(() => { expect(seenRunIds).toEqual(["run-analytics"]); diff --git a/apps/cli/src/next/auth/token.unit.test.ts b/apps/cli/src/next/auth/token.unit.test.ts index 86400c21a8..b420e388df 100644 --- a/apps/cli/src/next/auth/token.unit.test.ts +++ b/apps/cli/src/next/auth/token.unit.test.ts @@ -17,21 +17,15 @@ function expectInvalidTokenError(exit: Exit.Exit<unknown, unknown>) { describe("validateToken", () => { describe("valid tokens", () => { it.live("accepts sbp_ prefix with 40 lowercase hex chars", () => - Effect.gen(function* () { - yield* validateToken(`sbp_${VALID_HEX_40}`); - }), + validateToken(`sbp_${VALID_HEX_40}`), ); it.live("accepts sbp_oauth_ prefix with 40 lowercase hex chars", () => - Effect.gen(function* () { - yield* validateToken(`sbp_oauth_${VALID_HEX_40}`); - }), + validateToken(`sbp_oauth_${VALID_HEX_40}`), ); it.live("accepts all valid hex characters (a-f, 0-9)", () => - Effect.gen(function* () { - yield* validateToken("sbp_abcdef0123456789abcdef0123456789abcdef01"); - }), + validateToken("sbp_abcdef0123456789abcdef0123456789abcdef01"), ); }); diff --git a/apps/cli/src/next/commands/branches/create/create.command.ts b/apps/cli/src/next/commands/branches/create/create.command.ts index 5fad4fd6c9..cdce925cae 100644 --- a/apps/cli/src/next/commands/branches/create/create.command.ts +++ b/apps/cli/src/next/commands/branches/create/create.command.ts @@ -10,10 +10,8 @@ import { withCommandInstrumentation } from "../../../../shared/telemetry/command import { create } from "./create.handler.ts"; const branchesPlatformApiLayer = platformApiLayer.pipe(Layer.provide(credentialsLayer)); -const branchesRuntimeLayer = Layer.mergeAll( - branchesPlatformApiLayer, - projectLinkStateLayer, - commandRuntimeLayer(["branches", "create"]), +const branchesRuntimeLayer = Layer.mergeAll(branchesPlatformApiLayer, projectLinkStateLayer).pipe( + Layer.provideMerge(commandRuntimeLayer(["branches", "create"])), ); const BRANCH_REGIONS = [ diff --git a/apps/cli/src/next/commands/branches/create/create.handler.ts b/apps/cli/src/next/commands/branches/create/create.handler.ts index beb445dcbc..9fe6f82b36 100644 --- a/apps/cli/src/next/commands/branches/create/create.handler.ts +++ b/apps/cli/src/next/commands/branches/create/create.handler.ts @@ -26,12 +26,10 @@ const resolveBranchName = Effect.fnUntraced(function* (nameOpt: Option.Option<st const maybeGitBranch = yield* detectGitBranch(); if (Option.isNone(maybeGitBranch)) { - return yield* Effect.fail( - new NoBranchNameError({ - detail: "No branch name provided and no git branch detected.", - suggestion: "Provide a branch name: `supabase branches create <name>`", - }), - ); + return yield* new NoBranchNameError({ + detail: "No branch name provided and no git branch detected.", + suggestion: "Provide a branch name: `supabase branches create <name>`", + }); } const gitBranch = maybeGitBranch.value; @@ -52,13 +50,11 @@ const resolveBranchName = Effect.fnUntraced(function* (nameOpt: Option.Option<st ); if (!confirmed) { - return yield* Effect.fail( - new NoBranchNameError({ - detail: "Branch creation cancelled.", - suggestion: "Provide a branch name: `supabase branches create <name>`", - cancelled: true, - }), - ); + return yield* new NoBranchNameError({ + detail: "Branch creation cancelled.", + suggestion: "Provide a branch name: `supabase branches create <name>`", + cancelled: true, + }); } return { branchName: gitBranch, gitBranch: Option.some(gitBranch) }; @@ -75,12 +71,10 @@ export const create = Effect.fn("branches.create")(function* (flags: CreateFlags const maybeLinkState = yield* projectLinkState.load; if (Option.isNone(maybeLinkState)) { - return yield* Effect.fail( - new ProjectNotLinkedError({ - detail: "No project is linked in this directory.", - suggestion: "Run `supabase link` first.", - }), - ); + return yield* new ProjectNotLinkedError({ + detail: "No project is linked in this directory.", + suggestion: "Run `supabase link` first.", + }); } const { project } = maybeLinkState.value; diff --git a/apps/cli/src/next/commands/branches/create/create.integration.test.ts b/apps/cli/src/next/commands/branches/create/create.integration.test.ts index dae785c7df..255ac3ea36 100644 --- a/apps/cli/src/next/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/next/commands/branches/create/create.integration.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, V1CreateABranchOutput } from "@supabase/api/effect"; -import { Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; -import { ProjectLinkState } from "../../../config/project-link-state.service.ts"; +import { + ProjectLinkState, + ProjectNotLinkedError, +} from "../../../config/project-link-state.service.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { classifyCliCauseActionability } from "../../../../shared/telemetry/error-actionability.ts"; import { @@ -17,6 +20,7 @@ import { } from "../../../../../tests/helpers/mocks.ts"; import type { CreateFlags } from "./create.command.ts"; import { create } from "./create.handler.ts"; +import { BranchAlreadyExistsError, NoBranchNameError } from "../errors.ts"; // --------------------------------------------------------------------------- // Fixtures @@ -245,8 +249,15 @@ describe("branches create handler", () => { const exit = yield* create(BASE_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("NoBranchNameError"); - expect(JSON.stringify(exit)).toContain("cancelled"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(NoBranchNameError); + expect(error.value).toMatchObject({ _tag: "NoBranchNameError", cancelled: true }); + } + } }), ); @@ -256,8 +267,18 @@ describe("branches create handler", () => { const exit = yield* create(BASE_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("NoBranchNameError"); - expect(JSON.stringify(exit)).toContain("supabase branches create"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(NoBranchNameError); + expect(error.value).toMatchObject({ + _tag: "NoBranchNameError", + suggestion: "Provide a branch name: `supabase branches create <name>`", + }); + } + } }), ); @@ -283,7 +304,15 @@ describe("branches create handler", () => { const exit = yield* create(BASE_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("NoBranchNameError"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(NoBranchNameError); + expect(error.value).toMatchObject({ _tag: "NoBranchNameError" }); + } + } }), ); @@ -294,8 +323,18 @@ describe("branches create handler", () => { const exit = yield* create(flags).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("ProjectNotLinkedError"); - expect(JSON.stringify(exit)).toContain("supabase link"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(ProjectNotLinkedError); + expect(error.value).toMatchObject({ + _tag: "ProjectNotLinkedError", + suggestion: "Run `supabase link` first.", + }); + } + } }), ); @@ -413,8 +452,15 @@ describe("branches create handler", () => { const exit = yield* create(BASE_FLAGS).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("BranchAlreadyExistsError"); - expect(JSON.stringify(exit)).toContain("supabase branches create <name>"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(BranchAlreadyExistsError); + expect(error.value).toMatchObject({ _tag: "BranchAlreadyExistsError" }); + } + } }), ); @@ -425,8 +471,18 @@ describe("branches create handler", () => { const exit = yield* create(flags).pipe(Effect.provide(layer), Effect.exit); - expect(JSON.stringify(exit)).toContain("BranchAlreadyExistsError"); - expect(JSON.stringify(exit)).toContain("existing-branch"); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(BranchAlreadyExistsError); + expect(error.value).toMatchObject({ + _tag: "BranchAlreadyExistsError", + detail: 'A branch named "existing-branch" already exists.', + }); + } + } }), ); diff --git a/apps/cli/src/next/commands/branches/list/list.command.ts b/apps/cli/src/next/commands/branches/list/list.command.ts index 173264ce56..4cda4a4dbb 100644 --- a/apps/cli/src/next/commands/branches/list/list.command.ts +++ b/apps/cli/src/next/commands/branches/list/list.command.ts @@ -9,10 +9,8 @@ import { withCommandInstrumentation } from "../../../../shared/telemetry/command import { list } from "./list.handler.ts"; const branchesPlatformApiLayer = platformApiLayer.pipe(Layer.provide(credentialsLayer)); -const branchesRuntimeLayer = Layer.mergeAll( - branchesPlatformApiLayer, - projectLinkStateLayer, - commandRuntimeLayer(["branches", "list"]), +const branchesRuntimeLayer = Layer.mergeAll(branchesPlatformApiLayer, projectLinkStateLayer).pipe( + Layer.provideMerge(commandRuntimeLayer(["branches", "list"])), ); export const listBranchesCommand = Command.make("list").pipe( diff --git a/apps/cli/src/next/commands/branches/list/list.e2e.test.ts b/apps/cli/src/next/commands/branches/list/list.e2e.test.ts index 195b8a4bdc..e2594c0599 100644 --- a/apps/cli/src/next/commands/branches/list/list.e2e.test.ts +++ b/apps/cli/src/next/commands/branches/list/list.e2e.test.ts @@ -7,22 +7,19 @@ describe("supabase branches list", () => { test( "exits with an error and suggestion when the project is not linked", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["branches", "list"], { + () => + runSupabase(["branches", "list"], { env: { SUPABASE_ACCESS_TOKEN: "fake-token-for-testing" }, - }); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("supabase link"); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("supabase link"); + }), ); - test( - "--help exits successfully and describes the command", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["branches", "list", "--help"]); + test("--help exits successfully and describes the command", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["branches", "list", "--help"]).then(({ exitCode, stdout }) => { expect(exitCode).toBe(0); expect(stdout).toContain("List all remote branches"); - }, + }), ); }); diff --git a/apps/cli/src/next/commands/branches/list/list.handler.ts b/apps/cli/src/next/commands/branches/list/list.handler.ts index 645dee2956..ab15b3ce38 100644 --- a/apps/cli/src/next/commands/branches/list/list.handler.ts +++ b/apps/cli/src/next/commands/branches/list/list.handler.ts @@ -17,12 +17,10 @@ export const list = Effect.fn("branches.list")(function* () { const maybeLinkState = yield* projectLinkState.load; if (Option.isNone(maybeLinkState)) { - return yield* Effect.fail( - new ProjectNotLinkedError({ - detail: "No project is linked in this directory.", - suggestion: "Run `supabase link` first.", - }), - ); + return yield* new ProjectNotLinkedError({ + detail: "No project is linked in this directory.", + suggestion: "Run `supabase link` first.", + }); } const { project, active_branch } = maybeLinkState.value; diff --git a/apps/cli/src/next/commands/branches/list/list.integration.test.ts b/apps/cli/src/next/commands/branches/list/list.integration.test.ts index 08d1fef5f8..e8642b1661 100644 --- a/apps/cli/src/next/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/next/commands/branches/list/list.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient } from "@supabase/api/effect"; -import { Effect, Exit, Layer } from "effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -33,6 +33,20 @@ function makeBranch( }; } +function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string, detail?: string) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + expect(failure.value).toMatchObject({ _tag: tag }); + if (detail !== undefined) { + expect(failure.value).toMatchObject({ detail }); + } + } + } +} + const DEFAULT_LINK_STATE = { project: { ref: "parentrefabcdefghijk", @@ -180,12 +194,7 @@ describe("branches list handler", () => { const exit = yield* list().pipe(Effect.provide(layer), Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const cause = exit.cause; - expect(JSON.stringify(cause)).toContain("ProjectNotLinkedError"); - expect(JSON.stringify(cause)).toContain("supabase link"); - } + expectFailureTag(exit, "ProjectNotLinkedError", "No project is linked in this directory."); }), ); diff --git a/apps/cli/src/next/commands/branches/switch/switch.command.ts b/apps/cli/src/next/commands/branches/switch/switch.command.ts index 1b5a9fab42..20b1f81c16 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.command.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.command.ts @@ -11,10 +11,8 @@ import { switchBranch } from "./switch.handler.ts"; const branchesPlatformApiLayer = platformApiLayer.pipe(Layer.provide(credentialsLayer)); const branchesRuntimeLayer = provideProjectCommandRuntime( - Layer.mergeAll( - branchesPlatformApiLayer, - projectLinkStateLayer, - commandRuntimeLayer(["branches", "switch"]), + Layer.mergeAll(branchesPlatformApiLayer, projectLinkStateLayer).pipe( + Layer.provideMerge(commandRuntimeLayer(["branches", "switch"])), ), ); diff --git a/apps/cli/src/next/commands/branches/switch/switch.e2e.test.ts b/apps/cli/src/next/commands/branches/switch/switch.e2e.test.ts index 9295ae0335..1f5bfce671 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.e2e.test.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.e2e.test.ts @@ -4,25 +4,22 @@ import { runSupabase } from "../../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 10_000; describe("supabase branches switch", () => { - test( - "--help exits successfully and describes the command", - { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout } = await runSupabase(["branches", "switch", "--help"]); + test("--help exits successfully and describes the command", { timeout: E2E_TIMEOUT_MS }, () => + runSupabase(["branches", "switch", "--help"]).then(({ exitCode, stdout }) => { expect(exitCode).toBe(0); expect(stdout).toContain("Switch the active branch"); - }, + }), ); test( "exits with an error and suggestion when the project is not linked", { timeout: E2E_TIMEOUT_MS }, - async () => { - const { exitCode, stdout, stderr } = await runSupabase(["branches", "switch", "main"], { + () => + runSupabase(["branches", "switch", "main"], { env: { SUPABASE_ACCESS_TOKEN: "fake-token-for-testing" }, - }); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("supabase link"); - }, + }).then(({ exitCode, stdout, stderr }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("supabase link"); + }), ); }); diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index 1f3ad20e8f..42aabf1fe6 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -10,6 +10,7 @@ import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; +import { ProjectContext } from "../../../config/project-context.service.ts"; import { managedPortIntents } from "../../../config/managed-port-intents.ts"; import { ProjectLinkState, @@ -36,18 +37,17 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { const api = yield* PlatformApi; const cliConfig = yield* CliConfig; const projectHome = yield* ProjectHome; + const projectContext = yield* ProjectContext; const runtimeInfo = yield* RuntimeInfo; yield* output.intro("Switch branch"); const maybeLinkState = yield* projectLinkState.load; if (Option.isNone(maybeLinkState)) { - return yield* Effect.fail( - new ProjectNotLinkedError({ - detail: "No project is linked in this directory.", - suggestion: "Run `supabase link` first.", - }), - ); + return yield* new ProjectNotLinkedError({ + detail: "No project is linked in this directory.", + suggestion: "Run `supabase link` first.", + }); } const { project, active_branch } = maybeLinkState.value; @@ -63,12 +63,10 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { const query = opts.name.value; const found = branches.find((b) => b.name === query || b.project_ref === query); if (found === undefined) { - return yield* Effect.fail( - new BranchNotFoundError({ - detail: `Branch '${query}' not found.`, - suggestion: "Run `supabase branches list` to see available branches.", - }), - ); + return yield* new BranchNotFoundError({ + detail: `Branch '${query}' not found.`, + suggestion: "Run `supabase branches list` to see available branches.", + }); } target = found; } else if (output.interactive) { @@ -82,21 +80,17 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { ); const found = branches.find((b) => b.project_ref === selected); if (found === undefined) { - return yield* Effect.fail( - new BranchNotFoundError({ - detail: `Selected branch could not be resolved.`, - suggestion: "Run `supabase branches list` to see available branches.", - }), - ); + return yield* new BranchNotFoundError({ + detail: `Selected branch could not be resolved.`, + suggestion: "Run `supabase branches list` to see available branches.", + }); } target = found; } else { - return yield* Effect.fail( - new NonInteractiveError({ - detail: "No branch name provided.", - suggestion: "Run `supabase branches switch <name>` or use an interactive terminal.", - }), - ); + return yield* new NonInteractiveError({ + detail: "No branch name provided.", + suggestion: "Run `supabase branches switch <name>` or use an interactive terminal.", + }); } if (target.project_ref === active_branch.ref) { @@ -111,12 +105,14 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { projectDir: projectHome.projectRoot, }).pipe( Effect.map(Option.some), - Effect.catchTag("NoRunningStackError", () => Effect.succeed(Option.none())), - // Branch switching is also valid outside a local project checkout. In - // that case managed discovery cannot canonicalize the synthetic/nonexistent - // project root supplied by the command context, which is equivalent to no - // local stack being present for this lifecycle check. - Effect.catchTag("InvalidManagedIdentityError", () => Effect.succeed(Option.none())), + Effect.catchTags({ + NoRunningStackError: () => Effect.succeed(Option.none()), + // Branch switching is also valid outside a local project checkout. In + // that case managed discovery cannot canonicalize the synthetic/nonexistent + // project root supplied by the command context, which is equivalent to no + // local stack being present for this lifecycle check. + InvalidManagedIdentityError: () => Effect.succeed(Option.none()), + }), ); if (Option.isSome(stackCheck) && stackCheck.value.lifecycle === "running") { @@ -171,7 +167,9 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { ), launch.versions, ); - const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); + const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot, { + projectEnv: Option.getOrUndefined(projectContext.projectEnv), + }); const stackLayer = yield* daemonLayer({ cliVersion: CLI_VERSION, diff --git a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts index e222ee6400..c69f76a079 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- this integration test reads and asserts exact persisted JSON fixtures through the host filesystem. import { describe, expect, it } from "@effect/vitest"; import { makeApiClient } from "@supabase/api/effect"; import { Cause, Effect, Exit, Layer, Option, Predicate } from "effect"; @@ -8,7 +9,12 @@ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import type { BranchResponse } from "@supabase/api/effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; -import { emptyEnv, mockOutput, mockProjectLinkState } from "../../../../../tests/helpers/mocks.ts"; +import { + emptyEnv, + mockOutput, + mockProjectContext, + mockProjectLinkState, +} from "../../../../../tests/helpers/mocks.ts"; import { ProjectLinkState } from "../../../config/project-link-state.service.ts"; import { switchBranch } from "./switch.handler.ts"; import { makeRunningStackFixture } from "../../../../../tests/helpers/running-stack.ts"; @@ -155,7 +161,14 @@ function setup( const api = mockPlatformApi(opts.branches ?? [MAIN_BRANCH, DEV_BRANCH], { status: opts.status, }); - const layer = Layer.mergeAll(emptyEnv(), out.layer, state, api.layer, controlTransportLayer); + const layer = Layer.mergeAll( + emptyEnv(), + mockProjectContext(), + out.layer, + state, + api.layer, + controlTransportLayer, + ); return { out, layer, api }; } @@ -341,6 +354,7 @@ describe("branches switch handler", () => { const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH], { status: 503 }); const layer = Layer.mergeAll( emptyEnv(), + mockProjectContext(), out.layer, linkState, api.layer, @@ -406,7 +420,13 @@ describe("branches switch handler", () => { }), }), ); - const layer = Layer.mergeAll(fixture.baseLayer, out.layer, linkStateLayer, api.layer); + const layer = Layer.mergeAll( + fixture.baseLayer, + mockProjectContext(), + out.layer, + linkStateLayer, + api.layer, + ); return switchBranch({ name: Option.some("dev") }).pipe( Effect.provide(layer), Effect.exit, @@ -443,6 +463,7 @@ describe("branches switch handler", () => { const api = mockPlatformApi([MAIN_BRANCH, DEV_BRANCH]); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(DEFAULT_LINK_STATE), api.layer, diff --git a/apps/cli/src/next/commands/functions/delete/delete.command.ts b/apps/cli/src/next/commands/functions/delete/delete.command.ts index db28aac975..70300e00e5 100644 --- a/apps/cli/src/next/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/next/commands/functions/delete/delete.command.ts @@ -19,10 +19,9 @@ const config = { export type FunctionsDeleteFlags = CliCommand.Command.Config.Infer<typeof config>; -const functionsDeleteRuntimeLayer = Layer.mergeAll( - platformApiLayer.pipe(Layer.provide(credentialsLayer)), - projectLinkStateLayer, - commandRuntimeLayer(["functions", "delete"]), +const functionsDeleteRuntimeLayer = Layer.mergeAll(projectLinkStateLayer).pipe( + Layer.provideMerge(platformApiLayer.pipe(Layer.provide(credentialsLayer))), + Layer.provideMerge(commandRuntimeLayer(["functions", "delete"])), ); export const functionsDeleteCommand = Command.make("delete", config).pipe( diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts index 2a60801b34..35bdb5bfb1 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.command.ts @@ -68,13 +68,11 @@ const functionsDeployPlatformApiLayer = platformApiLayer.pipe( Layer.provide(Layer.mergeAll(credentialsLayer, functionsDeployCommandRuntimeLayer)), ); -const functionsDeployRuntimeLayer = Layer.mergeAll( - BunServices.layer, - functionsDeployPlatformApiLayer, - projectLinkStateLayer, - functionsDeployCommandRuntimeLayer, +const functionsDeployRuntimeLayer = BunServices.layer.pipe( + Layer.provideMerge(projectLinkStateLayer), + Layer.provideMerge(functionsDeployPlatformApiLayer), // `stdinLayer`: the `--prune` confirmation reads piped stdin on a non-TTY stdin. - stdinLayer, + Layer.provideMerge(stdinLayer), ); export const functionsDeployCommand = Command.make("deploy", config).pipe( diff --git a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts index d47f12fb1a..97aebb65b4 100644 --- a/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/next/commands/functions/deploy/deploy.integration.test.ts @@ -1,14 +1,25 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient, FunctionResponse } from "@supabase/api/effect"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; -import { BunServices } from "@effect/platform-bun"; +import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; import { createHash } from "node:crypto"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; -import { dirname, join, sep } from "node:path"; import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; -import { Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; +import { + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Schema, + Sink, + Stdio, + Stream, +} from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as EffectPath from "effect/Path"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -31,9 +42,14 @@ import { } from "../../../../../tests/helpers/mocks.ts"; import { functionsDeploy } from "./deploy.handler.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; +import { legacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; const PROJECT_REF = "abcdefghijklmnopqrst"; const BRANCH_REF = "branchrefabcdefghij"; +const { dirname, join, sep } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); +const jsonSchema = Schema.fromJsonString(Schema.Unknown); +const decodeJsonText = (text: string) => Schema.decodeSync(jsonSchema)(text); +const encodeJsonText = (value: unknown) => Schema.encodeUnknownSync(jsonSchema)(value); const LINK_STATE: ProjectLinkStateValue = { project: { @@ -78,7 +94,7 @@ function readJsonBody(request: HttpClientRequest.HttpClientRequest): unknown { if (request.body._tag !== "Uint8Array" || !request.body.contentType.includes("json")) { return undefined; } - return JSON.parse(new TextDecoder().decode(request.body.body)); + return decodeJsonText(new TextDecoder().decode(request.body.body)); } interface RecordedMultipart { @@ -87,7 +103,7 @@ interface RecordedMultipart { } function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-deploy-")); + return join(tmpdir(), `supabase-functions-deploy-${randomUUID()}`); } function compressedBundleHash(contents: string): string { @@ -102,20 +118,63 @@ function compressedBundleHash(contents: string): string { return createHash("sha256").update(compressed).digest("hex"); } -async function writeProjectConfig(cwd: string, content = 'project_id = "test-project"\n') { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), content); +const withFileSystem = <A>( + effect: Effect.Effect<A, PlatformError, FileSystem.FileSystem>, +): Effect.Effect<A, PlatformError, never> => effect.pipe(Effect.provide(BunFileSystem.layer)); + +const mkdir = (path: string, options?: { readonly recursive?: boolean }) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }), + ); + +const writeFile = (path: string, content: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, content); + }), + ); + +const realpath = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.realPath(path); + }), + ); + +const rm = (path: string, options?: { readonly recursive?: boolean; readonly force?: boolean }) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }), + ); + +function writeProjectConfig( + cwd: string, + content = 'project_id = "test-project"\n', +): Effect.Effect<void, PlatformError, never> { + return Effect.gen(function* () { + yield* mkdir(join(cwd, "supabase"), { recursive: true }); + yield* writeFile(join(cwd, "supabase", "config.toml"), content); + }); } -async function writeLocalFunction( +function writeLocalFunction( cwd: string, slug: string, source = "Deno.serve(() => new Response())\n", -) { +): Effect.Effect<void, PlatformError, never> { const functionDir = join(cwd, "supabase", "functions", slug); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), source); - await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); + return Effect.gen(function* () { + yield* mkdir(functionDir, { recursive: true }); + yield* writeFile(join(functionDir, "index.ts"), source); + yield* writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); + }); } function cliConfigLayer() { @@ -180,7 +239,7 @@ function jsonResponse( ): HttpClientResponse.HttpClientResponse { return HttpClientResponse.fromWeb( request, - new Response(JSON.stringify(body), { + new Response(encodeJsonText(body), { status, headers: { "content-type": "application/json", @@ -210,6 +269,7 @@ function mockDeployApi( readonly deployStatuses?: ReadonlyArray<number>; readonly bulkStatuses?: ReadonlyArray<number>; readonly listFunctions?: ReadonlyArray<unknown>; + readonly retryAfter?: string; } = {}, ) { const requests: RecordedRequest[] = []; @@ -263,7 +323,7 @@ function mockDeployApi( request, 429, { message: "Too Many Requests" }, - { "Retry-After": "0" }, + { "Retry-After": opts.retryAfter ?? "0" }, ); } const slug = Option.getOrElse( @@ -293,7 +353,7 @@ function mockDeployApi( request, 429, { message: "Too Many Requests" }, - { "Retry-After": "0" }, + { "Retry-After": opts.retryAfter ?? "0" }, ); } if (status !== 200) { @@ -381,9 +441,27 @@ function resolveDockerOutputPath(args: ReadonlyArray<string>): string { throw new Error(`unable to resolve host output path for ${dockerOutputPath}`); } -async function expectedDockerBind(pathname: string, mode: "ro" | "rw" = "ro") { - const hostPath = await realpath(pathname); - return `${hostPath}:${hostPath.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:${mode}`; +function writeDockerBundle(record: { + readonly command: string; + readonly args: ReadonlyArray<string>; +}): Effect.Effect<void, PlatformError, never> { + if (record.command !== "docker" || record.args[0] !== "run") { + return Effect.void; + } + const outputPath = resolveDockerOutputPath(record.args); + return mkdir(dirname(outputPath), { recursive: true }).pipe( + Effect.andThen(writeFile(outputPath, "eszip-test-output")), + ); +} + +function expectedDockerBind( + pathname: string, + mode: "ro" | "rw" = "ro", +): Effect.Effect<string, PlatformError, never> { + return Effect.gen(function* () { + const hostPath = yield* realpath(pathname); + return `${hostPath}:${hostPath.replaceAll("\\", "/").replace(/^[A-Za-z]:/, "")}:${mode}`; + }); } function mockChildProcessSpawner( @@ -391,7 +469,10 @@ function mockChildProcessSpawner( readonly exitCode?: number; readonly stdout?: string; readonly stderr?: string; - readonly onSpawn?: (record: { command: string; args: ReadonlyArray<string> }) => void; + readonly onSpawn?: (record: { + command: string; + args: ReadonlyArray<string>; + }) => Effect.Effect<void, PlatformError, never>; } = {}, ) { const spawned: Array<{ command: string; args: ReadonlyArray<string> }> = []; @@ -400,12 +481,14 @@ function mockChildProcessSpawner( layer: Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => - Effect.sync(() => { + Effect.gen(function* () { const cmd = command._tag === "StandardCommand" ? command.command : ""; const args = command._tag === "StandardCommand" ? command.args : []; const record = { command: cmd, args }; spawned.push(record); - opts.onSpawn?.(record); + if (opts.onSpawn !== undefined) { + yield* opts.onSpawn(record); + } return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1000 + spawned.length), @@ -436,7 +519,7 @@ function mockChildProcessSpawner( } function cleanupTempDir(path: string) { - return Effect.tryPromise(() => rm(path, { recursive: true, force: true })).pipe(Effect.orDie); + return rm(path, { recursive: true, force: true }).pipe(Effect.orDie); } function setup( @@ -450,12 +533,22 @@ function setup( readonly api?: Parameters<typeof mockDeployApi>[0]; /** Piped stdin lines consumed by the non-TTY `--prune` confirm read. */ readonly stdinInput?: string; + /** Explicit environment values consumed by Config-backed Docker bundling. */ + readonly env?: Readonly<Record<string, string>>; } = {}, ) { const out = mockOutput({ format: opts.format ?? "text", interactive: false }); const api = mockDeployApi(opts.api); const layer = Layer.mergeAll( BunServices.layer, + ...(opts.env === undefined + ? [] + : [ + ConfigProvider.layer( + ConfigProvider.fromEnv({ env: opts.env, preserveEmptyStrings: true }), + ), + ]), + legacyViperEnvLayer, out.layer, api.layer, cliConfigLayer(), @@ -479,9 +572,9 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => writeLocalFunction(tempDir, "bye-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeLocalFunction(tempDir, "bye-world"); const { out, api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy"], @@ -523,8 +616,8 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir); @@ -541,8 +634,8 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world"], @@ -554,7 +647,7 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer)); expect(api.multiparts[0]?.metadata).toBeDefined(); - const metadata = JSON.parse(api.multiparts[0]!.metadata!); + const metadata = decodeJsonText(api.multiparts[0]!.metadata!); expect(metadata).not.toHaveProperty("verify_jwt"); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); @@ -563,8 +656,8 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { api, layer } = setup(tempDir, { api: { listFunctions: [makeFunction({ slug: "hello-world", verify_jwt: false })] }, @@ -584,18 +677,13 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - "verify_jwt = false", - "", - ].join("\n"), + yield* writeProjectConfig( + tempDir, + ['project_id = "test-project"', '[functions."hello-world"]', "verify_jwt = false", ""].join( + "\n", ), ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeLocalFunction(tempDir, "hello-world"); const { api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world"], @@ -614,8 +702,8 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--no-verify-jwt=false"], @@ -634,31 +722,23 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."custom-entry"]', - 'entrypoint = "./functions/custom-entry/handler.ts"', - "", - ].join("\n"), - ), + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."custom-entry"]', + 'entrypoint = "./functions/custom-entry/handler.ts"', + "", + ].join("\n"), ); - yield* Effect.promise(() => - mkdir(join(tempDir, "supabase", "functions", "custom-entry"), { recursive: true }), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "custom-entry", "handler.ts"), - 'Deno.serve(() => new Response("custom"))\n', - ), + yield* mkdir(join(tempDir, "supabase", "functions", "custom-entry"), { recursive: true }); + yield* writeFile( + join(tempDir, "supabase", "functions", "custom-entry", "handler.ts"), + 'Deno.serve(() => new Response("custom"))\n', ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "custom-entry", "deno.json"), - '{"imports":{}}\n', - ), + yield* writeFile( + join(tempDir, "supabase", "functions", "custom-entry", "deno.json"), + '{"imports":{}}\n', ); const { out, api, layer } = setup(tempDir, { @@ -681,9 +761,9 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => writeLocalFunction(tempDir, "bye-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeLocalFunction(tempDir, "bye-world"); const { out, api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy"], @@ -714,6 +794,29 @@ describe("functions deploy", () => { }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); + it.live("uses normal backoff when Retry-After is an invalid date", () => { + const tempDir = makeTempDir(); + + return Effect.gen(function* () { + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + + const { out, layer } = setup(tempDir, { + rawArgs: ["functions", "deploy", "hello-world"], + api: { deployStatuses: [429, 201], retryAfter: "2024-99-99" }, + }); + + yield* functionsDeploy({ + ...BASE_FLAGS, + functionNames: ["hello-world"], + }).pipe(Effect.provide(layer)); + + expect(out.stderrText).toContain( + "Rate limit exceeded while deploying function hello-world. Retrying in 1s.\n", + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); + }); + // INC-699: a `bundleOnly` upload bumps the remote version without persisting // metadata, so a partially failed bulk deploy must still send the final PUT for // whatever uploaded — otherwise the remote metadata is stranded and every later @@ -723,9 +826,9 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => writeLocalFunction(tempDir, "bye-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeLocalFunction(tempDir, "bye-world"); const { out, api, layer } = setup(tempDir, { api: { deployStatuses: [201, 409] }, @@ -758,9 +861,9 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => writeLocalFunction(tempDir, "bye-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeLocalFunction(tempDir, "bye-world"); const { out, api, layer } = setup(tempDir, { api: { deployStatuses: [409, 400] }, @@ -790,9 +893,9 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => writeLocalFunction(tempDir, "bye-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeLocalFunction(tempDir, "bye-world"); const { api, layer } = setup(tempDir, { api: { deployStatuses: [201, 409], bulkStatuses: [400] }, @@ -821,21 +924,17 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - 'import_map = "./custom_import_map.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - writeFile(join(tempDir, "supabase", "custom_import_map.json"), '{"imports":{}}\n'), - ); + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'import_map = "./custom_import_map.json"', + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeFile(join(tempDir, "supabase", "custom_import_map.json"), '{"imports":{}}\n'); const { api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world"], @@ -864,12 +963,10 @@ describe("functions deploy", () => { const sharedDir = join(tempDir, "shared"); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(projectDir)); - yield* Effect.promise(() => writeLocalFunction(projectDir, "hello-world")); - yield* Effect.promise(() => mkdir(sharedDir, { recursive: true })); - yield* Effect.promise(() => - writeFile(join(sharedDir, "import_map.json"), '{"imports":{}}\n'), - ); + yield* writeProjectConfig(projectDir); + yield* writeLocalFunction(projectDir, "hello-world"); + yield* mkdir(sharedDir, { recursive: true }); + yield* writeFile(join(sharedDir, "import_map.json"), '{"imports":{}}\n'); const { api, layer } = setup(projectDir, { rawArgs: [ @@ -907,27 +1004,19 @@ describe("functions deploy", () => { const sharedDir = join(tempDir, "shared"); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(projectDir)); - yield* Effect.promise(() => - writeLocalFunction( - projectDir, - "hello-world", - 'import { value } from "lib"\nDeno.serve(() => new Response(value))\n', - ), - ); - yield* Effect.promise(() => mkdir(sharedDir, { recursive: true })); - yield* Effect.promise(() => - writeFile(join(sharedDir, "import_map.json"), '{"imports":{"lib":"./lib.ts"}}\n'), - ); - yield* Effect.promise(() => - writeFile( - join(sharedDir, "lib.ts"), - 'import { helper } from "./helper.ts"\nexport const value = helper\n', - ), + yield* writeProjectConfig(projectDir); + yield* writeLocalFunction( + projectDir, + "hello-world", + 'import { value } from "lib"\nDeno.serve(() => new Response(value))\n', ); - yield* Effect.promise(() => - writeFile(join(sharedDir, "helper.ts"), 'export const helper = "ok"\n'), + yield* mkdir(sharedDir, { recursive: true }); + yield* writeFile(join(sharedDir, "import_map.json"), '{"imports":{"lib":"./lib.ts"}}\n'); + yield* writeFile( + join(sharedDir, "lib.ts"), + 'import { helper } from "./helper.ts"\nexport const value = helper\n', ); + yield* writeFile(join(sharedDir, "helper.ts"), 'export const helper = "ok"\n'); const { api, layer } = setup(projectDir, { rawArgs: [ @@ -962,15 +1051,11 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => - mkdir(join(tempDir, "supabase", "functions", "hello-world"), { recursive: true }), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "index.ts"), - "Deno.serve(() => new Response())\n", - ), + yield* writeProjectConfig(tempDir); + yield* mkdir(join(tempDir, "supabase", "functions", "hello-world"), { recursive: true }); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + "Deno.serve(() => new Response())\n", ); const { api, layer } = setup(tempDir, { @@ -993,32 +1078,24 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - 'entrypoint = "./functions/hello-world/src/main.ts"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - mkdir(join(tempDir, "supabase", "functions", "hello-world", "src")), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "src", "main.ts"), - "Deno.serve(() => new Response())\n", - ), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "src", "deno.json"), - '{"imports":{}}\n', - ), + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'entrypoint = "./functions/hello-world/src/main.ts"', + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(join(tempDir, "supabase", "functions", "hello-world", "src")); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "src", "main.ts"), + "Deno.serve(() => new Response())\n", + ); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "src", "deno.json"), + '{"imports":{}}\n', ); const { api, layer } = setup(tempDir, { @@ -1046,33 +1123,25 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - 'entrypoint = "./functions/hello-world/src/main.ts"', - 'import_map = "./functions/hello-world/deno.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - mkdir(join(tempDir, "supabase", "functions", "hello-world", "src")), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "src", "main.ts"), - "Deno.serve(() => new Response())\n", - ), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "src", "deno.json"), - '{"imports":{}}\n', - ), + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'entrypoint = "./functions/hello-world/src/main.ts"', + 'import_map = "./functions/hello-world/deno.json"', + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(join(tempDir, "supabase", "functions", "hello-world", "src")); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "src", "main.ts"), + "Deno.serve(() => new Response())\n", + ); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "src", "deno.json"), + '{"imports":{}}\n', ); const { api, layer } = setup(tempDir, { @@ -1101,25 +1170,19 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => - writeLocalFunction( - tempDir, - "hello-world", - 'import { value } from "lib"\nDeno.serve(() => new Response(value))\n', - ), + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction( + tempDir, + "hello-world", + 'import { value } from "lib"\nDeno.serve(() => new Response(value))\n', ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "deno.json"), - '{"scopes":{"./":{"lib":"./lib.ts"}}}\n', - ), + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "deno.json"), + '{"scopes":{"./":{"lib":"./lib.ts"}}}\n', ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "lib.ts"), - 'export const value = "ok"\n', - ), + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "lib.ts"), + 'export const value = "ok"\n', ); const { api, layer } = setup(tempDir, { @@ -1139,13 +1202,11 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "deno.json"), - '{"imports":{"lib":{"path":"./lib.ts"}}}\n', - ), + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "deno.json"), + '{"imports":{"lib":{"path":"./lib.ts"}}}\n', ); const { layer } = setup(tempDir, { @@ -1169,25 +1230,19 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => - writeLocalFunction( - tempDir, - "hello-world", - 'import "https://deno.land/x/example/mod.ts"\nDeno.serve(() => new Response("ok"))\n', - ), + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction( + tempDir, + "hello-world", + 'import "https://deno.land/x/example/mod.ts"\nDeno.serve(() => new Response("ok"))\n', ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "deno.json"), - '{"scopes":{"https://deno.land/x/example/":{"dep":"./dep.ts"}}}\n', - ), + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "deno.json"), + '{"scopes":{"https://deno.land/x/example/":{"dep":"./dep.ts"}}}\n', ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "dep.ts"), - 'export const value = "remote-scope"\n', - ), + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "dep.ts"), + 'export const value = "remote-scope"\n', ); const { api, layer } = setup(tempDir, { @@ -1208,9 +1263,9 @@ describe("functions deploy", () => { const nestedDir = join(tempDir, "nested"); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => mkdir(nestedDir)); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(nestedDir); const { api, layer } = setup(nestedDir, { projectRoot: tempDir, @@ -1233,7 +1288,7 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir); @@ -1253,19 +1308,17 @@ describe("functions deploy", () => { const staticDir = join(tempDir, "supabase", "functions", "hello-world", "assets"); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - 'static_files = ["./functions/hello-world/assets"]', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => mkdir(staticDir)); + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'static_files = ["./functions/hello-world/assets"]', + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(staticDir); const { layer } = setup(tempDir); const error = yield* functionsDeploy({ @@ -1286,30 +1339,25 @@ describe("functions deploy", () => { const secretPath = join(outsideDir, "access-token.txt"); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - 'import_map = "./custom_import_map.json"', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => - writeLocalFunction( - tempDir, - "hello-world", - 'import { secret } from "creds"\nDeno.serve(() => new Response(secret))\n', - ), - ); - yield* Effect.promise(() => writeFile(secretPath, "secret-token")); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "custom_import_map.json"), - JSON.stringify({ imports: { creds: secretPath } }), - ), + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'import_map = "./custom_import_map.json"', + "", + ].join("\n"), + ); + yield* writeLocalFunction( + tempDir, + "hello-world", + 'import { secret } from "creds"\nDeno.serve(() => new Response(secret))\n', + ); + yield* mkdir(outsideDir, { recursive: true }); + yield* writeFile(secretPath, "secret-token"); + yield* writeFile( + join(tempDir, "supabase", "custom_import_map.json"), + encodeJsonText({ imports: { creds: secretPath } }), ); const { out, api, layer } = setup(tempDir, { @@ -1338,19 +1386,15 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "vendor.mjs"), - "export const x = 1;\n", - ), + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "vendor.mjs"), + "export const x = 1;\n", ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "deno.json"), - JSON.stringify({ imports: { "@x/": "./vendor.mjs/" } }), - ), + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "deno.json"), + encodeJsonText({ imports: { "@x/": "./vendor.mjs/" } }), ); const { out, api, layer } = setup(tempDir, { @@ -1386,28 +1430,24 @@ describe("functions deploy", () => { const sharedPath = join(repoRoot, "packages", "shared", "src", "index.ts"); return Effect.gen(function* () { - yield* Effect.promise(() => mkdir(join(repoRoot, ".git"), { recursive: true })); - yield* Effect.promise(() => writeProjectConfig(projectRoot)); - yield* Effect.promise(() => - writeLocalFunction( - projectRoot, - "hello-world", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), + yield* mkdir(join(repoRoot, ".git"), { recursive: true }); + yield* writeProjectConfig(projectRoot); + yield* writeLocalFunction( + projectRoot, + "hello-world", + [ + 'import { shared } from "@repo/shared"', + "Deno.serve(() => new Response(shared))", + "", + ].join("\n"), ); - yield* Effect.promise(() => mkdir(dirname(sharedPath), { recursive: true })); - yield* Effect.promise(() => writeFile(sharedPath, 'export const shared = "ok"\n')); - yield* Effect.promise(() => - writeFile( - join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), - JSON.stringify({ - imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, - }), - ), + yield* mkdir(dirname(sharedPath), { recursive: true }); + yield* writeFile(sharedPath, 'export const shared = "ok"\n'); + yield* writeFile( + join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), + encodeJsonText({ + imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, + }), ); const { out, api, layer } = setup(projectRoot, { @@ -1440,30 +1480,25 @@ describe("functions deploy", () => { const sharedPath = join(repoRoot, "packages", "shared", "src", "index.ts"); return Effect.gen(function* () { - yield* Effect.promise(() => - writeFile(join(repoRoot, ".git"), "gitdir: /tmp/worktree/.git\n"), - ); - yield* Effect.promise(() => writeProjectConfig(projectRoot)); - yield* Effect.promise(() => - writeLocalFunction( - projectRoot, - "hello-world", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => mkdir(dirname(sharedPath), { recursive: true })); - yield* Effect.promise(() => writeFile(sharedPath, 'export const shared = "ok"\n')); - yield* Effect.promise(() => - writeFile( - join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), - JSON.stringify({ - imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, - }), - ), + yield* mkdir(repoRoot, { recursive: true }); + yield* writeFile(join(repoRoot, ".git"), "gitdir: /tmp/worktree/.git\n"); + yield* writeProjectConfig(projectRoot); + yield* writeLocalFunction( + projectRoot, + "hello-world", + [ + 'import { shared } from "@repo/shared"', + "Deno.serve(() => new Response(shared))", + "", + ].join("\n"), + ); + yield* mkdir(dirname(sharedPath), { recursive: true }); + yield* writeFile(sharedPath, 'export const shared = "ok"\n'); + yield* writeFile( + join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), + encodeJsonText({ + imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, + }), ); const { api, layer } = setup(projectRoot, { @@ -1493,8 +1528,8 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 1 }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world"], @@ -1530,39 +1565,28 @@ describe("functions deploy", () => { const sharedPath = join(repoRoot, "packages", "shared", "src", "index.ts"); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => mkdir(join(repoRoot, ".git"), { recursive: true })); - yield* Effect.promise(() => writeProjectConfig(projectRoot)); - yield* Effect.promise(() => - writeLocalFunction( - projectRoot, - "hello-world", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => mkdir(dirname(sharedPath), { recursive: true })); - yield* Effect.promise(() => writeFile(sharedPath, 'export const shared = "ok"\n')); - yield* Effect.promise(() => - writeFile( - join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), - JSON.stringify({ - imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, - }), - ), + yield* mkdir(join(repoRoot, ".git"), { recursive: true }); + yield* writeProjectConfig(projectRoot); + yield* writeLocalFunction( + projectRoot, + "hello-world", + [ + 'import { shared } from "@repo/shared"', + "Deno.serve(() => new Response(shared))", + "", + ].join("\n"), + ); + yield* mkdir(dirname(sharedPath), { recursive: true }); + yield* writeFile(sharedPath, 'export const shared = "ok"\n'); + yield* writeFile( + join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), + encodeJsonText({ + imports: { "@repo/shared": "../../../../packages/shared/src/index.ts" }, + }), ); const { layer } = setup(projectRoot, { @@ -1577,9 +1601,7 @@ describe("functions deploy", () => { useDocker: true, }).pipe(Effect.provide(layer)); - expect(child.spawned.at(-1)?.args).toContain( - yield* Effect.promise(() => expectedDockerBind(sharedPath)), - ); + expect(child.spawned.at(-1)?.args).toContain(yield* expectedDockerBind(sharedPath)); }).pipe(Effect.ensuring(cleanupTempDir(repoRoot))); }); @@ -1590,29 +1612,25 @@ describe("functions deploy", () => { const sharedPath = join(outerRoot, "packages", "shared", "src", "index.ts"); return Effect.gen(function* () { - yield* Effect.promise(() => mkdir(repoRoot, { recursive: true })); - yield* Effect.promise(() => mkdir(join(repoRoot, ".git"), { recursive: true })); - yield* Effect.promise(() => writeProjectConfig(projectRoot)); - yield* Effect.promise(() => - writeLocalFunction( - projectRoot, - "hello-world", - [ - 'import { shared } from "@repo/shared"', - "Deno.serve(() => new Response(shared))", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => mkdir(dirname(sharedPath), { recursive: true })); - yield* Effect.promise(() => writeFile(sharedPath, 'export const shared = "blocked"\n')); - yield* Effect.promise(() => - writeFile( - join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), - JSON.stringify({ - imports: { "@repo/shared": "../../../../../packages/shared/src/index.ts" }, - }), - ), + yield* mkdir(repoRoot, { recursive: true }); + yield* mkdir(join(repoRoot, ".git"), { recursive: true }); + yield* writeProjectConfig(projectRoot); + yield* writeLocalFunction( + projectRoot, + "hello-world", + [ + 'import { shared } from "@repo/shared"', + "Deno.serve(() => new Response(shared))", + "", + ].join("\n"), + ); + yield* mkdir(dirname(sharedPath), { recursive: true }); + yield* writeFile(sharedPath, 'export const shared = "blocked"\n'); + yield* writeFile( + join(projectRoot, "supabase", "functions", "hello-world", "deno.json"), + encodeJsonText({ + imports: { "@repo/shared": "../../../../../packages/shared/src/index.ts" }, + }), ); const { out, api, layer } = setup(projectRoot, { @@ -1635,8 +1653,8 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 1 }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--output-format", "json"], @@ -1667,35 +1685,24 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - "[edge_runtime]", - "deno_version = 1", - '[functions."hello-world"]', - 'import_map = "./custom_import_map.json"', - "verify_jwt = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - writeFile(join(tempDir, "supabase", "custom_import_map.json"), '{"imports":{}}\n'), - ); + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + "[edge_runtime]", + "deno_version = 1", + '[functions."hello-world"]', + 'import_map = "./custom_import_map.json"', + "verify_jwt = false", + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* writeFile(join(tempDir, "supabase", "custom_import_map.json"), '{"imports":{}}\n'); const { out, api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -1748,9 +1755,7 @@ describe("functions deploy", () => { expect(api.requests[1]?.urlParams).toContain("verify_jwt=false"); expect(child.spawned.at(-1)?.args).toContain("public.ecr.aws/supabase/edge-runtime:v1.68.4"); expect(child.spawned.at(-1)?.args).toContain( - yield* Effect.promise(() => - expectedDockerBind(join(tempDir, "supabase", "custom_import_map.json")), - ), + yield* expectedDockerBind(join(tempDir, "supabase", "custom_import_map.json")), ); expect(out.stderrText).toContain("Bundling Function: hello-world\n"); expect(out.stderrText).toContain("Deploying Function: hello-world (script size:"); @@ -1768,20 +1773,22 @@ describe("functions deploy", () => { exitCode: 0, onSpawn: (record) => { if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - outputPath = resolveDockerOutputPath(record.args); - if (!outputPath.startsWith(`${sharedOutputRoot}${sep}`)) { - return; + return Effect.void; } - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); + return Effect.gen(function* () { + outputPath = resolveDockerOutputPath(record.args); + if (!outputPath.startsWith(`${sharedOutputRoot}${sep}`)) { + return; + } + yield* mkdir(dirname(outputPath), { recursive: true }); + yield* writeFile(outputPath, "eszip-test-output"); + }); }, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -1800,44 +1807,22 @@ describe("functions deploy", () => { it.live("forwards only NPM_CONFIG_REGISTRY to the Docker bundler", () => { const tempDir = makeTempDir(); - const previousRegistry = process.env["NPM_CONFIG_REGISTRY"]; - const previousToken = process.env["NPM_AUTH_TOKEN"]; const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, - }); - - const restoreEnv = Effect.sync(() => { - if (previousRegistry === undefined) { - delete process.env["NPM_CONFIG_REGISTRY"]; - } else { - process.env["NPM_CONFIG_REGISTRY"] = previousRegistry; - } - if (previousToken === undefined) { - delete process.env["NPM_AUTH_TOKEN"]; - } else { - process.env["NPM_AUTH_TOKEN"] = previousToken; - } + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.sync(() => { - process.env["NPM_CONFIG_REGISTRY"] = "https://npm.pkg.github.com"; - process.env["NPM_AUTH_TOKEN"] = "test-token"; - }); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], childLayer: child.layer, + env: { + NPM_CONFIG_REGISTRY: "https://npm.pkg.github.com", + NPM_AUTH_TOKEN: "test-token", + }, }); yield* functionsDeploy({ @@ -1858,20 +1843,18 @@ describe("functions deploy", () => { expect(forwardedEnv).toContain("NPM_CONFIG_REGISTRY"); expect(forwardedEnv).not.toContain("NPM_AUTH_TOKEN"); expect(forwardedEnv).not.toContain("NPM_AUTH_TOKEN=test-token"); - }).pipe(Effect.ensuring(Effect.all([cleanupTempDir(tempDir), restoreEnv]))); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("rejects unsupported edge runtime Deno versions for Docker bundling", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - ['project_id = "test-project"', "[edge_runtime]", "deno_version = 3", ""].join("\n"), - ), + yield* writeProjectConfig( + tempDir, + ['project_id = "test-project"', "[edge_runtime]", "deno_version = 3", ""].join("\n"), ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeLocalFunction(tempDir, "hello-world"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -1895,19 +1878,12 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 0, stdout: "verbose bundle output\n", - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir, { format: "json", @@ -1931,8 +1907,8 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -1960,19 +1936,12 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -2012,19 +1981,12 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const expectedHash = compressedBundleHash("eszip-test-output"); const { api, out, layer } = setup(tempDir, { @@ -2057,27 +2019,16 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - rm(join(tempDir, "supabase", "functions", "hello-world", "deno.json")), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "package.json"), - '{"dependencies":{"chalk":"^5.0.0"}}\n', - ), + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* rm(join(tempDir, "supabase", "functions", "hello-world", "deno.json")); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "package.json"), + '{"dependencies":{"chalk":"^5.0.0"}}\n', ); const { api, layer } = setup(tempDir, { @@ -2112,19 +2063,12 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { layer } = setup(tempDir, { rawArgs: ["--debug", "functions", "deploy", "hello-world", "--use-docker"], @@ -2145,23 +2089,14 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => mkdir(join(tempDir, "supabase", ".temp"), { recursive: true })); - yield* Effect.promise(() => - writeFile(join(tempDir, "supabase", ".temp", "edge-runtime-version"), "9.9.9\n"), - ); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(join(tempDir, "supabase", ".temp"), { recursive: true }); + yield* writeFile(join(tempDir, "supabase", ".temp", "edge-runtime-version"), "9.9.9\n"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -2185,32 +2120,23 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); const staticFile = join(tempDir, "supabase", "shared", "index.html"); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "test-project"', - '[functions."hello-world"]', - 'static_files = ["./shared/*.html"]', - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => mkdir(dirname(staticFile), { recursive: true })); - yield* Effect.promise(() => writeFile(staticFile, "<h1>hello</h1>\n")); + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "test-project"', + '[functions."hello-world"]', + 'static_files = ["./shared/*.html"]', + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(dirname(staticFile), { recursive: true }); + yield* writeFile(staticFile, "<h1>hello</h1>\n"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -2224,9 +2150,7 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer)); expect(child.spawned).toHaveLength(5); - expect(child.spawned.at(-1)?.args).toContain( - yield* Effect.promise(() => expectedDockerBind(staticFile)), - ); + expect(child.spawned.at(-1)?.args).toContain(yield* expectedDockerBind(staticFile)); }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); @@ -2235,12 +2159,10 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 1 }); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - ['project_id = "test-project"', '[functions."disabled-fn"]', "enabled = false", ""].join( - "\n", - ), + yield* writeProjectConfig( + tempDir, + ['project_id = "test-project"', '[functions."disabled-fn"]', "enabled = false", ""].join( + "\n", ), ); @@ -2265,12 +2187,10 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 1 }); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - ['project_id = "test-project"', '[functions."disabled-fn"]', "enabled = false", ""].join( - "\n", - ), + yield* writeProjectConfig( + tempDir, + ['project_id = "test-project"', '[functions."disabled-fn"]', "enabled = false", ""].join( + "\n", ), ); @@ -2302,31 +2222,27 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "base-project"', - '[functions."hello-world"]', - 'entrypoint = "./functions/hello-world/src/main.ts"', - "", - "[remotes.preview]", - `project_id = "${PROJECT_REF}"`, - '[remotes.preview.functions."hello-world"]', - "verify_jwt = false", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - yield* Effect.promise(() => - mkdir(join(tempDir, "supabase", "functions", "hello-world", "src"), { recursive: true }), - ); - yield* Effect.promise(() => - writeFile( - join(tempDir, "supabase", "functions", "hello-world", "src", "main.ts"), - 'Deno.serve(() => new Response("remote"))\n', - ), + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "base-project"', + '[functions."hello-world"]', + 'entrypoint = "./functions/hello-world/src/main.ts"', + "", + "[remotes.preview]", + `project_id = "${PROJECT_REF}"`, + '[remotes.preview.functions."hello-world"]', + "verify_jwt = false", + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); + yield* mkdir(join(tempDir, "supabase", "functions", "hello-world", "src"), { + recursive: true, + }); + yield* writeFile( + join(tempDir, "supabase", "functions", "hello-world", "src", "main.ts"), + 'Deno.serve(() => new Response("remote"))\n', ); const { api, layer } = setup(tempDir, { @@ -2350,20 +2266,18 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - [ - 'project_id = "base-project"', - "[remotes.preview]", - 'project_id = "qrstuvwxyzabcdefghij"', - "[remotes.preview.edge_runtime]", - "deno_version = 3", - "", - ].join("\n"), - ), - ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig( + tempDir, + [ + 'project_id = "base-project"', + "[remotes.preview]", + 'project_id = "qrstuvwxyzabcdefghij"', + "[remotes.preview.edge_runtime]", + "deno_version = 3", + "", + ].join("\n"), + ); + yield* writeLocalFunction(tempDir, "hello-world"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--project-ref", "qrstuvwxyzabcdefghij"], @@ -2388,7 +2302,7 @@ describe("functions deploy", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { api, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello.world"], childLayer: child.layer, @@ -2409,7 +2323,7 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "--use-api", "--use-docker"], }); @@ -2436,7 +2350,7 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "--use-api", "--use-docker=false"], }); @@ -2464,7 +2378,7 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "--jobs", "2"], }); @@ -2484,8 +2398,8 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-api", "--jobs", "2"], @@ -2506,8 +2420,8 @@ describe("functions deploy", () => { describe("--prune confirmation (Go parity: deploy.go:180-195, console.go:64-102)", () => { // Strip ANSI SGR (bold slugs via `legacyBold`) so byte-assertions are stable // whether or not the test stderr supports color. - // eslint-disable-next-line no-control-regex - const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); + const stripSgr = (text: string) => + text.replace(new RegExp(`${String.fromCodePoint(0x1b)}\\[[0-9;]*m`, "gu"), ""); const PRUNE_PROMPT = "Do you want to delete the following Functions from your project?\n \u2022 remote-only\n\n [y/N] "; @@ -2526,8 +2440,8 @@ describe("functions deploy", () => { it.live("--yes auto-confirms with the Go prompt echo and deletes the orphan", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, api, layer } = pruneSetup(tempDir); yield* functionsDeploy({ @@ -2553,8 +2467,8 @@ describe("functions deploy", () => { it.live("piped `n` declines the prune and cancels like Go", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, api, layer } = pruneSetup(tempDir, "n\n"); const exit = yield* Effect.exit( @@ -2568,7 +2482,7 @@ describe("functions deploy", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("FunctionDeployCancelledError"); + expect(encodeJsonText(exit.cause)).toContain("FunctionDeployCancelledError"); } // The piped answer is echoed after the label like Go's non-TTY PromptText. expect(stripSgr(out.stderrText)).toContain(`${PRUNE_PROMPT}n\n`); @@ -2579,8 +2493,8 @@ describe("functions deploy", () => { it.live("piped `y` confirms the prune like Go", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, api, layer } = pruneSetup(tempDir, "y\n"); yield* functionsDeploy({ @@ -2598,8 +2512,8 @@ describe("functions deploy", () => { it.live("empty stdin takes the No default and cancels", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, api, layer } = pruneSetup(tempDir); const exit = yield* Effect.exit( @@ -2626,8 +2540,8 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir, 'project_id = ""\n')); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir, 'project_id = ""\n'); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world"], @@ -2651,13 +2565,11 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.promise(() => - writeProjectConfig( - tempDir, - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), + yield* writeProjectConfig( + tempDir, + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), ); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeLocalFunction(tempDir, "hello-world"); const { out, layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world"], @@ -2681,22 +2593,12 @@ describe("functions deploy", () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0, - onSpawn: (record) => { - if (record.command !== "docker" || record.args[0] !== "run") { - return; - } - const outputPath = resolveDockerOutputPath(record.args); - mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, "eszip-test-output"); - }, + onSpawn: writeDockerBundle, }); - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; - return Effect.gen(function* () { - yield* Effect.promise(() => writeProjectConfig(tempDir)); - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); + yield* writeProjectConfig(tempDir); + yield* writeLocalFunction(tempDir, "hello-world"); const { layer } = setup(tempDir, { rawArgs: ["functions", "deploy", "hello-world", "--use-docker"], @@ -2715,18 +2617,7 @@ describe("functions deploy", () => { command: "docker", args: ["image", "inspect", `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`], }); - }).pipe( - Effect.ensuring(cleanupTempDir(tempDir)), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); }); diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index 570aad4c63..da69c584bb 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -1,5 +1,5 @@ import { DEFAULT_MANAGED_STACK_NAME, httpTransportClientLayer } from "@supabase/stack/effect"; -import { Layer } from "effect"; +import { Effect, Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { provideProjectCommandRuntime } from "../../../config/project-runtime.layer.ts"; @@ -44,7 +44,7 @@ export const functionsDevCommand = Command.make("dev", flags).pipe( }, ]), Command.withHandler((flags) => - functionsDev(flags).pipe(withCommandInstrumentation(), withJsonErrorHandling), + Effect.scoped(functionsDev(flags)).pipe(withCommandInstrumentation(), withJsonErrorHandling), ), Command.provide( provideProjectCommandRuntime( diff --git a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts index be5905217b..ecc35acb9a 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- this e2e test drives a real compiled CLI subprocess and HTTP server. import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts index 144c71c390..494c2f8949 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts @@ -6,10 +6,10 @@ import { resolveProjectSubtree, } from "@supabase/config"; import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; -import { Effect, Option, Redacted } from "effect"; -import { basename, dirname, join, resolve } from "node:path"; +import { ConfigProvider, Effect, Option, Path, Redacted } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { collectConfigEnvironment } from "../../../../shared/runtime/config-environment.ts"; export interface FunctionsDevConfigOptions { readonly envFile: Option.Option<string>; @@ -25,9 +25,9 @@ function reveal(value: string | Redacted.Redacted<string>): string { return Redacted.isRedacted(value) ? Redacted.value(value) : value; } -function absoluteProjectPath(supabaseDir: string, path: string): string { +function absoluteProjectPath(pathService: Path.Path, supabaseDir: string, path: string): string { const withoutDotSlash = path.startsWith("./") ? path.slice(2) : path; - return resolve(supabaseDir, withoutDotSlash); + return pathService.resolve(supabaseDir, withoutDotSlash); } export const resolveFunctionsBundle = Effect.fnUntraced(function* ( @@ -35,11 +35,16 @@ export const resolveFunctionsBundle = Effect.fnUntraced(function* ( ) { const projectHome = yield* ProjectHome; const runtimeInfo = yield* RuntimeInfo; + const path = yield* Path.Path; + const provider = yield* ConfigProvider.ConfigProvider; + const baseEnv = yield* collectConfigEnvironment(provider); const projectEnvironment = yield* loadProjectEnvironment({ cwd: projectHome.projectRoot, - baseEnv: process.env, + baseEnv, + }); + const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot, { + projectEnv: projectEnvironment ?? undefined, }); - const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot); const projectConfig = projectEnvironment === null || loadedConfig === null ? undefined @@ -71,24 +76,25 @@ export const resolveFunctionsBundle = Effect.fnUntraced(function* ( ...(projectConfig === undefined ? {} : { config: projectConfig }), }); const envFilePath = Option.match(opts.envFile, { - onNone: () => join(projectHome.supabaseDir, "functions", ".env"), - onSome: (path) => resolve(runtimeInfo.cwd, path), + onNone: () => path.join(projectHome.supabaseDir, "functions", ".env"), + onSome: (envFile) => path.resolve(runtimeInfo.cwd, envFile), }); + const loadedEnv = yield* loadDotEnvFile(envFilePath); return { - env: yield* loadDotEnvFile(envFilePath), + env: loadedEnv, functions: Object.entries(manifest) .filter(([, config]) => config.enabled) .map(([name, config]) => ({ name, verifyJWT: opts.noVerifyJwt ? false : config.verify_jwt, - entrypointPath: absoluteProjectPath(projectHome.supabaseDir, config.entrypoint), + entrypointPath: absoluteProjectPath(path, projectHome.supabaseDir, config.entrypoint), importMapPath: config.import_map === "" ? null - : absoluteProjectPath(projectHome.supabaseDir, config.import_map), - staticFiles: config.static_files.map((path) => - absoluteProjectPath(projectHome.supabaseDir, path), + : absoluteProjectPath(path, projectHome.supabaseDir, config.import_map), + staticFiles: config.static_files.map((staticPath) => + absoluteProjectPath(path, projectHome.supabaseDir, staticPath), ), env: config.env, })), @@ -98,6 +104,7 @@ export const resolveFunctionsBundle = Effect.fnUntraced(function* ( export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Option.Option<string>) { const projectHome = yield* ProjectHome; const runtimeInfo = yield* RuntimeInfo; + const path = yield* Path.Path; return [ { @@ -106,11 +113,11 @@ export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Opti }, ...(Option.isSome(envFile) ? (() => { - const envFilePath = resolve(runtimeInfo.cwd, envFile.value); + const envFilePath = path.resolve(runtimeInfo.cwd, envFile.value); return [ { - path: dirname(envFilePath), - names: [basename(envFilePath)], + path: path.dirname(envFilePath), + names: [path.basename(envFilePath)], }, ]; })() diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index 97951e09a2..34726e0efb 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Exit, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { functionsDevWatchPaths, resolveFunctionsBundle } from "./functions-dev-config.ts"; @@ -14,21 +10,18 @@ import { } from "./functions-dev-edge-runtime-config.ts"; import { connectOrStartFunctionsDevStack } from "./functions-dev-runtime.ts"; -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-dev-")); -} - -function projectLayer(cwd: string) { - const projectHomeDir = join(cwd, ".supabase"); +function projectLayer(cwd: string, path: Path.Path, env: Readonly<Record<string, string>> = {}) { + const projectHomeDir = path.join(cwd, ".supabase"); return Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })), Layer.succeed( RuntimeInfo, RuntimeInfo.of({ cwd, platform: process.platform, arch: process.arch, - homeDir: join(cwd, ".home"), + homeDir: path.join(cwd, ".home"), execPath: process.execPath, pid: process.pid, }), @@ -37,41 +30,57 @@ function projectLayer(cwd: string) { ProjectHome, ProjectHome.of({ projectRoot: cwd, - supabaseDir: join(cwd, "supabase"), + supabaseDir: path.join(cwd, "supabase"), projectHomeDir, - projectLinkPath: join(projectHomeDir, "project.json"), - projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), + projectLinkPath: path.join(projectHomeDir, "project.json"), + projectLocalVersionsPath: path.join(projectHomeDir, "local-versions.json"), ensureProjectHomeDir: Effect.void, }), ), ); } +function withTempProject<A, E, R>( + body: (cwd: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, E, R>, +) { + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-functions-dev-" }); + return yield* body(cwd, fs, path); + }), + ); +} + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + describe("functions dev config", () => { it("exports the start-or-connect block for future dev orchestration", () => { expect(connectOrStartFunctionsDevStack).toBeTypeOf("function"); }); it.live("resolves project functions, environment and absolute paths before stack handoff", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => - mkdir(join(cwd, "supabase", "functions", "hello", "assets"), { recursive: true }), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "functions", "hello", "index.ts"), "export {};\n"), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", "functions", "hello", "deno.json"), "{}\n"), - ); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "FUNCTION_VALUE=resolved-secret\n"), - ); - yield* Effect.tryPromise(() => writeFile(join(cwd, "custom.env"), "SHARED=custom\n")); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase", "functions", "hello", "assets"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "functions", "hello", "index.ts"), + "export {};\n", + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", "functions", "hello", "deno.json"), + "{}\n", + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", ".env"), + "FUNCTION_VALUE=resolved-secret\n", + ); + yield* fs.writeFileString(path.join(cwd, "custom.env"), "SHARED=custom\n"); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), `[functions.hello] verify_jwt = true entrypoint = "./functions/hello/index.ts" @@ -81,57 +90,86 @@ static_files = ["./functions/hello/assets/*"] [functions.hello.env] FUNCTION_VALUE = "env(FUNCTION_VALUE)" `, + ); + + const bundle = yield* resolveFunctionsBundle({ + envFile: Option.some("./custom.env"), + noVerifyJwt: true, + }); + + expect(bundle).toEqual({ + env: { SHARED: "custom" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: path.join(cwd, "supabase", "functions", "hello", "index.ts"), + importMapPath: path.join(cwd, "supabase", "functions", "hello", "deno.json"), + staticFiles: [path.join(cwd, "supabase", "functions", "hello", "assets", "*")], + env: { FUNCTION_VALUE: "resolved-secret" }, + }, + ], + }); + }).pipe(Effect.provide(projectLayer(cwd, path))), + ).pipe(Effect.provide(BunServices.layer)); + }); + + it.effect("resolves function paths from the injected shell environment", () => { + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase", "functions", "hello"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "functions", "hello", "index.ts"), + "export {};\n", + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + `[functions.hello] +verify_jwt = true +entrypoint = "env(FUNCTION_ENTRYPOINT)" +`, + ); + + const bundle = yield* resolveFunctionsBundle({ + envFile: Option.none(), + noVerifyJwt: false, + }); + + expect(bundle.functions[0]?.entrypointPath).toBe( + path.join(cwd, "supabase", "functions", "hello", "index.ts"), + ); + }).pipe( + Effect.provide( + projectLayer(cwd, path, { + FUNCTION_ENTRYPOINT: "./functions/hello/index.ts", + }), ), - ); - - const bundle = yield* resolveFunctionsBundle({ - envFile: Option.some("./custom.env"), - noVerifyJwt: true, - }); - - expect(bundle).toEqual({ - env: { SHARED: "custom" }, - functions: [ - { - name: "hello", - verifyJWT: false, - entrypointPath: join(cwd, "supabase", "functions", "hello", "index.ts"), - importMapPath: join(cwd, "supabase", "functions", "hello", "deno.json"), - staticFiles: [join(cwd, "supabase", "functions", "hello", "assets", "*")], - env: { FUNCTION_VALUE: "resolved-secret" }, - }, - ], - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); + ), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("selects supabase and explicit env directory watch paths", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - const paths = yield* functionsDevWatchPaths(Option.some("./custom.env")); - - expect(paths).toEqual([ - { path: join(cwd, "supabase"), names: ["functions", "config.toml", "config.json"] }, - { path: cwd, names: ["custom.env"] }, - ]); - }).pipe(Effect.provide(projectLayer(cwd))); + return withTempProject((cwd, _fs, path) => + Effect.gen(function* () { + const paths = yield* functionsDevWatchPaths(Option.some("./custom.env")); + + expect(paths).toEqual([ + { path: path.join(cwd, "supabase"), names: ["functions", "config.toml", "config.json"] }, + { path: cwd, names: ["custom.env"] }, + ]); + }).pipe(Effect.provide(projectLayer(cwd, path))), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("resolves edge runtime config from project config and secrets", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), - ); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), `project_id = "test" [edge_runtime] @@ -142,58 +180,84 @@ inspector_port = 8123 api_key = "env(EDGE_API_KEY)" literal = "literal-secret" `, - ), - ); - - const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); - - expect(result.config).toEqual({ - enabled: true, - inspectorPort: 8123, - policy: "oneshot", - env: { - API_KEY: "edge-secret", - LITERAL: "literal-secret", - }, - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); + ); + + const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); + + expect(result.config).toEqual({ + enabled: true, + inspectorPort: 8123, + policy: "oneshot", + env: { + API_KEY: "edge-secret", + LITERAL: "literal-secret", + }, + }); + }).pipe(Effect.provide(projectLayer(cwd, path))), + ).pipe(Effect.provide(BunServices.layer)); }); - it.live("fails when edge runtime is disabled for functions dev", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.json"), - JSON.stringify({ edge_runtime: { enabled: false } }), + it.effect( + "resolves edge runtime numeric and boolean fields from the injected shell environment", + () => { + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + `[edge_runtime] +enabled = "env(EDGE_RUNTIME_ENABLED)" +inspector_port = "env(EDGE_RUNTIME_INSPECTOR_PORT)" +policy = "oneshot" +`, + ); + + const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); + + expect(result.config).toEqual({ + enabled: true, + inspectorPort: 8123, + policy: "oneshot", + env: {}, + }); + }).pipe( + Effect.provide( + projectLayer(cwd, path, { + EDGE_RUNTIME_ENABLED: "true", + EDGE_RUNTIME_INSPECTOR_PORT: "8123", + }), + ), ), - ); - - const error = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.flip); + ).pipe(Effect.provide(BunServices.layer)); + }, + ); - expect(error).toBeInstanceOf(FunctionsDevEdgeRuntimeDisabledError); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); + it.live("fails when edge runtime is disabled for functions dev", () => { + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.json"), + encodeJson({ edge_runtime: { enabled: false } }), + ); + + const error = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.flip); + + expect(error).toBeInstanceOf(FunctionsDevEdgeRuntimeDisabledError); + }).pipe(Effect.provide(projectLayer(cwd, path))), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("does not resolve a lowercase-named env() reference in edge runtime secrets", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "lowercase_env_var=should-not-resolve\n"), - ); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(cwd, "supabase", ".env"), + "lowercase_env_var=should-not-resolve\n", + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), `project_id = "test" [edge_runtime] @@ -203,36 +267,29 @@ inspector_port = 8123 [edge_runtime.secrets] lowercase_secret = "env(lowercase_env_var)" `, - ), - ); - - const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); - - expect(result.config).toEqual({ - enabled: true, - inspectorPort: 8123, - policy: "oneshot", - env: { - LOWERCASE_SECRET: "env(lowercase_env_var)", - }, - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); + ); + + const result = yield* resolveFunctionsDevEdgeRuntimeConfig(); + + expect(result.config).toEqual({ + enabled: true, + inspectorPort: 8123, + policy: "oneshot", + env: { + LOWERCASE_SECRET: "env(lowercase_env_var)", + }, + }); + }).pipe(Effect.provide(projectLayer(cwd, path))), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("does not split a comma-separated string literal for an array field", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(cwd, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"), - ); - yield* Effect.tryPromise(() => - writeFile( - join(cwd, "supabase", "config.toml"), + return withTempProject((cwd, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), "EDGE_API_KEY=edge-secret\n"); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), `project_id = "test" [auth] @@ -246,15 +303,12 @@ inspector_port = 8123 api_key = "env(EDGE_API_KEY)" literal = "literal-secret" `, - ), - ); + ); - const exit = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.exit); + const exit = yield* resolveFunctionsDevEdgeRuntimeConfig().pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), - Effect.provide(projectLayer(cwd)), - ); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(projectLayer(cwd, path))), + ).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts index 8b19629c01..6257e71fd4 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts @@ -5,8 +5,9 @@ import { type ProjectConfig, } from "@supabase/config"; import type { EdgeRuntimeConfig } from "@supabase/stack/effect"; -import { Data, Effect, Redacted } from "effect"; +import { ConfigProvider, Data, Effect, Redacted } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; +import { collectConfigEnvironment } from "../../../../shared/runtime/config-environment.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -93,7 +94,15 @@ function toStackEdgeRuntimeConfig(config: ResolvedProjectEdgeRuntimeConfig): Edg export const resolveFunctionsDevEdgeRuntimeConfig = Effect.fnUntraced(function* () { const projectHome = yield* ProjectHome; - const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot); + const provider = yield* ConfigProvider.ConfigProvider; + const baseEnv = yield* collectConfigEnvironment(provider); + const projectEnv = yield* loadProjectEnvironment({ + cwd: projectHome.projectRoot, + baseEnv, + }); + const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot, { + projectEnv: projectEnv ?? undefined, + }); if (loadedConfig === null) { const config = {}; @@ -103,11 +112,6 @@ export const resolveFunctionsDevEdgeRuntimeConfig = Effect.fnUntraced(function* }; } - const projectEnv = yield* loadProjectEnvironment({ - cwd: projectHome.projectRoot, - baseEnv: process.env, - }); - if (projectEnv === null) { const config = {}; return { @@ -124,12 +128,10 @@ export const resolveFunctionsDevEdgeRuntimeConfig = Effect.fnUntraced(function* const config = toStackEdgeRuntimeConfig(resolved); if (config.enabled === false) { - return yield* Effect.fail( - new FunctionsDevEdgeRuntimeDisabledError({ - detail: "`supabase functions dev` requires edge_runtime.enabled to be true.", - suggestion: "Set edge_runtime.enabled to true or remove the override, then save again.", - }), - ); + return yield* new FunctionsDevEdgeRuntimeDisabledError({ + detail: "`supabase functions dev` requires edge_runtime.enabled to be true.", + suggestion: "Set edge_runtime.enabled to true or remove the override, then save again.", + }); } return { diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index d77829c913..6fc41869f4 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -1,10 +1,20 @@ import { connectLayer, daemonLayer, Stack, type EdgeRuntimeConfig } from "@supabase/stack/effect"; import { loadProjectConfig } from "@supabase/config"; -import { Context, Duration, Effect, FileSystem, Layer, Option, Stream } from "effect"; -import { join } from "node:path"; +import { + Context, + DateTime, + Duration, + Effect, + FileSystem, + Layer, + Option, + Path, + Stream, +} from "effect"; import { CLI_VERSION } from "../../../../shared/cli/version.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; +import { ProjectContext } from "../../../config/project-context.service.ts"; import { projectLocalServiceVersionsLayer } from "../../../config/project-local-service-versions.layer.ts"; import { projectLinkStateLayer } from "../../../config/project-link-state.layer.ts"; import { resolveServiceVersionContext } from "../../../config/service-version-resolution.ts"; @@ -47,6 +57,7 @@ type StackService = typeof Stack.Service; const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptions) { const cliConfig = yield* CliConfig; const projectHome = yield* ProjectHome; + const projectContext = yield* ProjectContext; const runtimeInfo = yield* RuntimeInfo; const output = yield* Output; @@ -54,7 +65,9 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio yield* ensureProjectStateIgnored(projectHome.projectRoot); const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); - const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); + const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot, { + projectEnv: Option.getOrUndefined(projectContext.projectEnv), + }); const stackConfig = { ...withServiceVersions(toStartStackConfig([], "docker"), serviceVersionContext.runtimeVersions), // Functions dev explicitly requires Edge Runtime even when the project @@ -114,7 +127,7 @@ function logEntryStream(stack: StackService) { return stack.subscribeLogs("edge-runtime").pipe( Stream.map((entry) => ({ type: "log-entry" as const, - timestamp: new Date(entry.timestamp).toISOString(), + timestamp: DateTime.formatIso(DateTime.makeUnsafe(entry.timestamp)), service: entry.service, stream: entry.stream, line: entry.line, @@ -125,8 +138,9 @@ function logEntryStream(stack: StackService) { const ensureFunctionsDirectory = Effect.fnUntraced(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const projectHome = yield* ProjectHome; - yield* fs.makeDirectory(join(projectHome.supabaseDir, "functions"), { recursive: true }); + yield* fs.makeDirectory(path.join(projectHome.supabaseDir, "functions"), { recursive: true }); }); function watchEventMatches(spec: FunctionsDevWatchPath, event: FileWatchEvent): boolean { diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts index 8628ac6b66..29da1ab427 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Duration, Effect, Fiber, Layer, Queue, Stream } from "effect"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Deferred, Duration, Effect, Fiber, Layer, Path, Queue, Stream } from "effect"; import { FileWatcher, type FileWatchEvent, @@ -57,6 +57,7 @@ describe("functions dev runtime", () => { return Effect.gen(function* () { const watcher = makeFakeFileWatcher(); + const path = yield* Path.Path; let emitted = false; const fiber = yield* watchPaths([{ path: cwd, names: ["supabase"] }]).pipe( @@ -72,19 +73,20 @@ describe("functions dev runtime", () => { ); yield* watcher.awaitWatch(cwd); - yield* watcher.emit(cwd, [{ path: join(cwd, "supabase", "functions"), type: "create" }]); + yield* watcher.emit(cwd, [{ path: path.join(cwd, "supabase", "functions"), type: "create" }]); yield* Fiber.join(fiber); expect(emitted).toBe(true); - }); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("marks config json changes as project config changes", () => { const cwd = "/tmp/supabase-functions-dev-watch"; - const supabaseDir = join(cwd, "supabase"); return Effect.gen(function* () { const watcher = makeFakeFileWatcher(); + const path = yield* Path.Path; + const supabaseDir = path.join(cwd, "supabase"); const fiber = yield* watchPaths([ { path: supabaseDir, names: ["functions", "config.toml", "config.json"] }, @@ -98,11 +100,11 @@ describe("functions dev runtime", () => { yield* watcher.awaitWatch(supabaseDir); yield* watcher.emit(supabaseDir, [ - { path: join(supabaseDir, "config.json"), type: "create" }, + { path: path.join(supabaseDir, "config.json"), type: "create" }, ]); const changes = yield* Fiber.join(fiber); expect(changes.at(0)?.touchesProjectConfig).toBe(true); - }); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/commands/functions/download/download.command.ts b/apps/cli/src/next/commands/functions/download/download.command.ts index 1d24797470..0191c1859b 100644 --- a/apps/cli/src/next/commands/functions/download/download.command.ts +++ b/apps/cli/src/next/commands/functions/download/download.command.ts @@ -38,13 +38,17 @@ const config = { export type FunctionsDownloadFlags = CliCommand.Command.Config.Infer<typeof config>; +const functionsDownloadCommandRuntimeLayer = commandRuntimeLayer(["functions", "download"]); +const functionsDownloadPlatformApiLayer = platformApiLayer.pipe( + Layer.provide(credentialsLayer), + Layer.provide(functionsDownloadCommandRuntimeLayer), +); + const functionsDownloadRuntimeLayer = Layer.mergeAll( - BunServices.layer, - platformApiLayer.pipe(Layer.provide(credentialsLayer)), + functionsDownloadPlatformApiLayer, projectLinkStateLayer, - commandRuntimeLayer(["functions", "download"]), makeGoProxyLayer(), -); +).pipe(Layer.provideMerge(BunServices.layer)); export const functionsDownloadCommand = Command.make("download", config).pipe( Command.withDescription( diff --git a/apps/cli/src/next/commands/functions/download/download.integration.test.ts b/apps/cli/src/next/commands/functions/download/download.integration.test.ts index c7f8c0bd27..757dfc3afa 100644 --- a/apps/cli/src/next/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/next/commands/functions/download/download.integration.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { FunctionResponse, makeApiClient } from "@supabase/api/effect"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; -import { existsSync, mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Effect, FileSystem, Layer, Option, Stdio } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as EffectPath from "effect/Path"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -34,10 +35,12 @@ import { } from "../../../../shared/functions/download.errors.ts"; import { invalidFunctionSlugDetail } from "../../../../shared/functions/functions.shared.ts"; import { functionsDownload } from "./download.handler.ts"; +import { legacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; const PROJECT_REF = "abcdefghijklmnopqrst"; const BRANCH_REF = "branchrefabcdefghij"; type ResponseBody = string | Blob; +const { join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); const LINK_STATE: ProjectLinkStateValue = { project: { @@ -64,12 +67,72 @@ const BASE_FLAGS: FunctionsDownloadFlags = { }; function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-download-")); + return join(tmpdir(), `supabase-functions-download-${randomUUID()}`); } -async function writeProjectConfig(cwd: string) { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), ""); +const withFileSystem = <A>( + effect: Effect.Effect<A, PlatformError, FileSystem.FileSystem>, +): Effect.Effect<A, PlatformError, never> => effect.pipe(Effect.provide(BunFileSystem.layer)); + +const mkdir = (path: string, options?: { readonly recursive?: boolean }) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }), + ); + +function readFile(path: string, encoding: "utf8"): Effect.Effect<string, PlatformError, never>; +function readFile(path: string): Effect.Effect<Uint8Array, PlatformError, never>; +function readFile(path: string, encoding?: "utf8") { + return withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return encoding === undefined ? yield* fs.readFile(path) : yield* fs.readFileString(path); + }), + ); +} + +const writeFile = (path: string, content: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, content); + }), + ); + +const rm = (path: string, options?: { readonly recursive?: boolean; readonly force?: boolean }) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }), + ); + +const symlink = (target: string, path: string, _type?: "junction") => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.symlink(target, path); + }), + ); + +const exists = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }), + ); + +const cleanupTempDir = (path: string) => + rm(path, { recursive: true, force: true }).pipe(Effect.orDie); + +function writeProjectConfig(cwd: string): Effect.Effect<void, PlatformError, never> { + return Effect.gen(function* () { + yield* mkdir(join(cwd, "supabase"), { recursive: true }); + yield* writeFile(join(cwd, "supabase", "config.toml"), ""); + }); } function textResponse( @@ -299,6 +362,7 @@ function setup( const proxy = mockLegacyGoProxy(); const layer = Layer.mergeAll( emptyEnv(), + legacyViperEnvLayer, out.layer, api.layer, proxy.layer, @@ -382,7 +446,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, api, layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -396,14 +460,10 @@ describe("functions download", () => { ); expect(api.acceptHeaders).toContain("multipart/form-data"); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), + yield* readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), ).toBe("console.log('hello')"); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "utils.ts"), "utf8"), - ), + yield* readFile(join(tempDir, "supabase", "functions", "hello-world", "utils.ts"), "utf8"), ).toBe("export const value = 1;"); expect(out.stderrText).toContain("Downloading Function: hello-world\n"); expect(out.stderrText).toContain( @@ -412,9 +472,7 @@ describe("functions download", () => { expect(out.stderrText).toContain( `Downloaded Function hello-world from project abcdefghijklmnopqrst.\n`, ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("downloads multipart file parts under any field name", () => { @@ -436,7 +494,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -446,13 +504,9 @@ describe("functions download", () => { yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), + yield* readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), ).toBe("console.log('source')"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live( @@ -477,7 +531,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { functionBySlug: { "hello-world": makeFunction({ @@ -493,16 +547,17 @@ describe("functions download", () => { yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + yield* readFile( + join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + "utf8", ), ).toBe("console.log('empty metadata')"); expect( - existsSync(join(tempDir, "supabase", "functions", "hello-world", "source", "index.ts")), + yield* exists( + join(tempDir, "supabase", "functions", "hello-world", "source", "index.ts"), + ), ).toBe(false); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -526,7 +581,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(subdirectory, { recursive: true })); + yield* mkdir(subdirectory, { recursive: true }); const { layer } = setup(subdirectory, { projectRoot: tempDir, bodyBySlug: { @@ -537,14 +592,10 @@ describe("functions download", () => { yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), + yield* readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), ).toBe("console.log('hello')"); - expect(existsSync(join(subdirectory, "supabase", "functions"))).toBe(false); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* exists(join(subdirectory, "supabase", "functions"))).toBe(false); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("preserves binary file bytes from multipart responses", () => { @@ -569,7 +620,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -580,14 +631,10 @@ describe("functions download", () => { expect( new Uint8Array( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "asset.bin")), - ), + yield* readFile(join(tempDir, "supabase", "functions", "hello-world", "asset.bin")), ), ).toEqual(binary); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live( @@ -619,7 +666,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { functionBySlug: { "hello-world": makeFunction({ @@ -635,21 +682,18 @@ describe("functions download", () => { yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + yield* readFile( + join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + "utf8", ), ).toBe("console.log('abs')"); expect( - yield* Effect.tryPromise(() => - readFile( - join(tempDir, "supabase", "functions", "hello-world", "lib", "utils.ts"), - "utf8", - ), + yield* readFile( + join(tempDir, "supabase", "functions", "hello-world", "lib", "utils.ts"), + "utf8", ), ).toBe("export const util = 2;"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -687,7 +731,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir, { list: [ makeFunction({ slug: "hello-world", name: "Hello World" }), @@ -705,22 +749,19 @@ describe("functions download", () => { }).pipe(Effect.provide(layer)); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), + yield* readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), ).toBe("console.log('hello')"); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "goodbye-world", "index.ts"), "utf8"), + yield* readFile( + join(tempDir, "supabase", "functions", "goodbye-world", "index.ts"), + "utf8", ), ).toBe("console.log('bye')"); expect(out.stderrText).toContain("Found 2 function(s) to download\n"); expect(out.stderrText).toContain( "Successfully downloaded all functions from project abcdefghijklmnopqrst\n", ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("rejects a malicious remote slug from download-all before any per-slug work", () => { @@ -731,7 +772,7 @@ describe("functions download", () => { const maliciousSlug = "../../../../../poc-escaped-outside-project"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { api, layer } = setup(tempDir, { list: [makeFunction({ slug: maliciousSlug })], }); @@ -756,10 +797,8 @@ describe("functions download", () => { expect(api.requests).toEqual([ `https://api.supabase.com/v1/projects/${PROJECT_REF}/functions`, ]); - expect(existsSync(join(tempDir, "supabase", "functions"))).toBe(false); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* exists(join(tempDir, "supabase", "functions"))).toBe(false); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live( @@ -768,7 +807,7 @@ describe("functions download", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); // Go's generated client unmarshals the whole `[]FunctionResponse` // array in one `json.Unmarshal` call // (`apps/cli-go/pkg/api/client.gen.go:22186-22208`); a type mismatch @@ -798,10 +837,8 @@ describe("functions download", () => { expect(api.requests).toEqual([ `https://api.supabase.com/v1/projects/${PROJECT_REF}/functions`, ]); - expect(existsSync(join(tempDir, "supabase", "functions"))).toBe(false); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* exists(join(tempDir, "supabase", "functions"))).toBe(false); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -824,7 +861,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir, { list: [makeFunction({ slug: "hello-world" })], bodyBySlug: { @@ -840,9 +877,7 @@ describe("functions download", () => { expect(out.stderrText).toContain( "Successfully downloaded all functions from project abcdefghijklmnopqrst\n", ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("uses --use-api without delegating to the Go proxy", () => { @@ -864,7 +899,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer, proxy } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -878,16 +913,14 @@ describe("functions download", () => { }).pipe(Effect.provide(layer)); expect(proxy.calls).toEqual([]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("delegates --legacy-bundle with the linked project ref to the Go proxy", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer, proxy } = setup(tempDir, { rawArgs: ["functions", "download", "hello-world", "--legacy-bundle"], }); @@ -900,9 +933,7 @@ describe("functions download", () => { expect(proxy.calls).toEqual([ ["functions", "download", "hello-world", "--project-ref", PROJECT_REF, "--legacy-bundle"], ]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live( @@ -912,7 +943,7 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer, proxy } = setup(tempDir, { bodyBySlug: { "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, @@ -936,12 +967,10 @@ describe("functions download", () => { expect(runCommand?.args).toContain("unbundle"); expect(out.stderrText).toContain("Downloading function: hello-world\n"); // No `--debug` — the temp eszip file is removed after the run. - expect(existsSync(join(tempDir, "supabase", ".temp", "output_hello-world.eszip"))).toBe( + expect(yield* exists(join(tempDir, "supabase", ".temp", "output_hello-world.eszip"))).toBe( false, ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -950,7 +979,7 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer, proxy } = setup(tempDir, { format: "json", bodyBySlug: { @@ -983,9 +1012,7 @@ describe("functions download", () => { }, }), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("lists remote functions and downloads each natively via Docker in machine mode", () => { @@ -993,7 +1020,7 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer, proxy } = setup(tempDir, { format: "json", list: [makeFunction({ slug: "hello-world" }), makeFunction({ slug: "goodbye-world" })], @@ -1028,9 +1055,7 @@ describe("functions download", () => { }, }), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live( @@ -1040,7 +1065,7 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer, proxy } = setup(tempDir, { bodyBySlug: { "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, @@ -1066,9 +1091,7 @@ describe("functions download", () => { (spawned) => spawned.command === "docker" && spawned.args[0] === "run", ), ).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -1094,7 +1117,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart }, rawArgs: ["functions", "download", "hello-world", "--use-docker"], @@ -1109,13 +1132,12 @@ describe("functions download", () => { expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); expect(out.stderrText).toContain("WARNING: Docker is not running\n"); expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), + yield* readFile( + join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + "utf8", ), ).toBe("console.log('fallback')"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -1134,7 +1156,7 @@ describe("functions download", () => { const rawEszipBytes = new Uint8Array([0, 1, 2, 253, 254, 255, 10, 13, 0, 128, 200]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": { @@ -1154,13 +1176,11 @@ describe("functions download", () => { useDocker: true, }).pipe(Effect.provide(layer)); - const written = yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", ".temp", "output_hello-world.eszip")), + const written = yield* readFile( + join(tempDir, "supabase", ".temp", "output_hello-world.eszip"), ); expect(new Uint8Array(written)).toEqual(rawEszipBytes); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -1170,7 +1190,7 @@ describe("functions download", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer, proxy } = setup(tempDir, { format: "json", list: [], @@ -1196,9 +1216,7 @@ describe("functions download", () => { data: { function_slugs: [], project_ref: PROJECT_REF }, }), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -1206,7 +1224,7 @@ describe("functions download", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer, proxy } = setup(tempDir, { format: "json", listStatus: 503, @@ -1227,9 +1245,7 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(proxy.calls).toEqual([]); expect(proxy.captureCalls).toEqual([]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("rejects mutually exclusive compatibility flags", () => { @@ -1257,9 +1273,7 @@ describe("functions download", () => { ); expect(api.requests).toHaveLength(0); expect(proxy.calls).toHaveLength(0); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("still rejects the bundler mutex when --use-docker=false is explicit", () => { @@ -1289,16 +1303,14 @@ describe("functions download", () => { ); expect(api.requests).toHaveLength(0); expect(proxy.calls).toHaveLength(0); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("rejects invalid slugs before calling the API", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { api, layer } = setup(tempDir); const error = yield* functionsDownload({ @@ -1308,31 +1320,27 @@ describe("functions download", () => { expect(error).toBeInstanceOf(InvalidFunctionSlugError); expect(api.requests).toHaveLength(0); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("fails when neither a linked project nor --project-ref is available", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { linked: false }); const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); expect(error).toBeInstanceOf(ProjectNotLinkedError); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("prints the Go-style empty-state line when no functions exist", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir, { list: [], }); @@ -1343,16 +1351,14 @@ describe("functions download", () => { }).pipe(Effect.provide(layer)); expect(out.stderrText).toBe("No functions found in project abcdefghijklmnopqrst\n"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("fails when the response is not multipart", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": { @@ -1365,16 +1371,14 @@ describe("functions download", () => { const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("fails when the multipart boundary is absent from the response body", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": { @@ -1390,9 +1394,7 @@ describe("functions download", () => { expect(error.message).toBe( "failed to read form: multipart response is missing its opening boundary", ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("fails when a multipart file has malformed content disposition", () => { @@ -1414,7 +1416,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1425,9 +1427,7 @@ describe("functions download", () => { expect(error).toBeInstanceOf(InvalidFunctionDownloadResponseError); expect(error.message).toBe("failed to parse content disposition: malformed filename"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("writes structured success data in JSON mode for native downloads", () => { @@ -1449,7 +1449,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir, { format: "json", bodyBySlug: { @@ -1469,16 +1469,14 @@ describe("functions download", () => { }, }), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps list transport errors with Go-style wording", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { listError: new Error("network error"), }); @@ -1490,16 +1488,14 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe("failed to list functions: network error"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps unexpected list statuses with Go-style wording", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { listStatus: 503, listBody: { message: "unavailable" }, @@ -1512,16 +1508,14 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe('unexpected list functions status 503: {"message":"unavailable"}'); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps body transport errors with Go-style wording", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyErrorBySlug: { "hello-world": new Error("network error"), @@ -1532,16 +1526,14 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe("failed to download function: network error"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps unexpected body statuses with Go-style wording", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": { @@ -1556,9 +1548,7 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe("Error status 503: unavailable"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps eszip body transport errors with Go-style wording (Docker path)", () => { @@ -1566,7 +1556,7 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyErrorBySlug: { "hello-world": new Error("network error"), @@ -1585,9 +1575,7 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe("failed to get function body: network error"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps unexpected eszip body statuses with Go-style wording (Docker path)", () => { @@ -1595,7 +1583,7 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": { @@ -1615,9 +1603,7 @@ describe("functions download", () => { expect(error).toBeInstanceOf(Error); expect(error.message).toBe("Error status 503: unavailable"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("maps metadata fallback transport errors with Go-style wording", () => { @@ -1639,7 +1625,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1658,9 +1644,7 @@ describe("functions download", () => { expect(error.message).toBe( 'Failed to download Function hello-world on the Supabase project: {"message":"downstream unavailable"}', ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("honors Supabase-Path headers for files shared across functions", () => { @@ -1689,7 +1673,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1704,14 +1688,10 @@ describe("functions download", () => { yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer)); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "secret.env"), "utf8"), - ), - ).toBe("SECRET=1"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* readFile(join(tempDir, "supabase", "functions", "secret.env"), "utf8")).toBe( + "SECRET=1", + ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("rejects Supabase-Path headers that escape the functions directory", () => { @@ -1734,7 +1714,7 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1744,9 +1724,7 @@ describe("functions download", () => { const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("rejects a functions directory symlinked outside the project", () => { @@ -1769,10 +1747,9 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); - yield* Effect.tryPromise(() => - symlink(outsideDir, join(tempDir, "supabase", "functions"), "junction"), - ); + yield* writeProjectConfig(tempDir); + yield* mkdir(outsideDir, { recursive: true }); + yield* symlink(outsideDir, join(tempDir, "supabase", "functions"), "junction"); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1785,8 +1762,8 @@ describe("functions download", () => { }).pipe( Effect.ensuring( Effect.all([ - Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true })), - Effect.tryPromise(() => rm(outsideDir, { recursive: true, force: true })), + rm(tempDir, { recursive: true, force: true }), + rm(outsideDir, { recursive: true, force: true }), ]).pipe(Effect.orDie), ), ); @@ -1812,7 +1789,9 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => symlink(outsideDir, join(tempDir, "supabase"), "junction")); + yield* mkdir(tempDir, { recursive: true }); + yield* mkdir(outsideDir, { recursive: true }); + yield* symlink(outsideDir, join(tempDir, "supabase"), "junction"); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1822,12 +1801,12 @@ describe("functions download", () => { const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); - expect(existsSync(join(outsideDir, "functions"))).toBe(false); + expect(yield* exists(join(outsideDir, "functions"))).toBe(false); }).pipe( Effect.ensuring( Effect.all([ - Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true })), - Effect.tryPromise(() => rm(outsideDir, { recursive: true, force: true })), + rm(tempDir, { recursive: true, force: true }), + rm(outsideDir, { recursive: true, force: true }), ]).pipe(Effect.orDie), ), ); @@ -1855,8 +1834,9 @@ describe("functions download", () => { ]); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(functionDir, { recursive: true })); - yield* Effect.tryPromise(() => symlink(outsideDir, join(functionDir, "lib"), "junction")); + yield* mkdir(outsideDir, { recursive: true }); + yield* mkdir(functionDir, { recursive: true }); + yield* symlink(outsideDir, join(functionDir, "lib"), "junction"); const { layer } = setup(tempDir, { bodyBySlug: { "hello-world": multipart, @@ -1866,12 +1846,12 @@ describe("functions download", () => { const error = yield* functionsDownload(BASE_FLAGS).pipe(Effect.provide(layer), Effect.flip); expect(error).toBeInstanceOf(UnsafeFunctionDownloadPathError); - expect(existsSync(join(outsideDir, "new-directory"))).toBe(false); + expect(yield* exists(join(outsideDir, "new-directory"))).toBe(false); }).pipe( Effect.ensuring( Effect.all([ - Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true })), - Effect.tryPromise(() => rm(outsideDir, { recursive: true, force: true })), + rm(tempDir, { recursive: true, force: true }), + rm(outsideDir, { recursive: true, force: true }), ]).pipe(Effect.orDie), ), ); @@ -1881,7 +1861,7 @@ describe("functions download", () => { const tempDir = makeTempDir(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { out, layer } = setup(tempDir, { format: "json", bodyBySlug: { @@ -1895,9 +1875,7 @@ describe("functions download", () => { yield* functionsDownload(BASE_FLAGS).pipe(withJsonErrorHandling, Effect.provide(layer)); expect(out.messages).toContainEqual(expect.objectContaining({ type: "fail" })); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); describe("Go's Config.Validate/env-override parity is legacy-only (CLI-1963)", () => { @@ -1908,10 +1886,8 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(tempDir, "supabase", "config.toml"), 'project_id = ""\n'), - ); + yield* mkdir(join(tempDir, "supabase"), { recursive: true }); + yield* writeFile(join(tempDir, "supabase", "config.toml"), 'project_id = ""\n'); const { out, layer, proxy } = setup(tempDir, { bodyBySlug: { "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, @@ -1929,9 +1905,7 @@ describe("functions download", () => { ), ).toBe(true); expect(out.stderrText).toContain("Downloading function: hello-world\n"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -1942,12 +1916,10 @@ describe("functions download", () => { const child = mockChildProcessSpawner({ exitCode: 0 }); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(tempDir, "supabase", "config.toml"), - ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), - ), + yield* mkdir(join(tempDir, "supabase"), { recursive: true }); + yield* writeFile( + join(tempDir, "supabase", "config.toml"), + ['project_id = "test-project"', "", "[db]", "major_version = 12", ""].join("\n"), ); const { out, layer, proxy } = setup(tempDir, { bodyBySlug: { @@ -1966,9 +1938,7 @@ describe("functions download", () => { ), ).toBe(true); expect(out.stderrText).toContain("Downloading function: hello-world\n"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); @@ -1977,11 +1947,9 @@ describe("functions download", () => { () => { const tempDir = makeTempDir(); const child = mockChildProcessSpawner({ exitCode: 0 }); - const previous = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeProjectConfig(tempDir)); + yield* writeProjectConfig(tempDir); const { layer, proxy } = setup(tempDir, { bodyBySlug: { "hello-world": { body: "fake-eszip-bytes", contentType: "application/octet-stream" }, @@ -1999,18 +1967,7 @@ describe("functions download", () => { expect(runCommand?.args).toContain( `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) { - delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; - } else { - process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = previous; - } - }), - ), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }, ); }); diff --git a/apps/cli/src/next/commands/functions/functions.shared.ts b/apps/cli/src/next/commands/functions/functions.shared.ts index 3407b7f136..53151d04bc 100644 --- a/apps/cli/src/next/commands/functions/functions.shared.ts +++ b/apps/cli/src/next/commands/functions/functions.shared.ts @@ -12,12 +12,10 @@ export const resolveProjectRef = Effect.fnUntraced(function* (projectRef: Option const projectLinkState = yield* ProjectLinkState; const maybeLinkState = yield* projectLinkState.load; if (Option.isNone(maybeLinkState)) { - return yield* Effect.fail( - new ProjectNotLinkedError({ - detail: "No project is linked in this directory.", - suggestion: "Run `supabase link` first or pass `--project-ref`.", - }), - ); + return yield* new ProjectNotLinkedError({ + detail: "No project is linked in this directory.", + suggestion: "Run `supabase link` first or pass `--project-ref`.", + }); } return maybeLinkState.value.project.ref; diff --git a/apps/cli/src/next/commands/functions/list/list.command.ts b/apps/cli/src/next/commands/functions/list/list.command.ts index 3e08e93bcd..bbb5d14c30 100644 --- a/apps/cli/src/next/commands/functions/list/list.command.ts +++ b/apps/cli/src/next/commands/functions/list/list.command.ts @@ -1,18 +1,25 @@ -import { BunServices } from "@effect/platform-bun"; import { Layer } from "effect"; import { Command } from "effect/unstable/cli"; import { FetchHttpClient } from "effect/unstable/http"; import { credentialsLayer } from "../../../auth/credentials.layer.ts"; +import { + discoveredProjectContextLayer, + provideProjectCommandRuntime, +} from "../../../config/project-runtime.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { withCommandInstrumentation } from "../../../../shared/telemetry/command-instrumentation.ts"; import { functionsList } from "./list.handler.ts"; const functionsListRuntimeLayer = Layer.mergeAll( - BunServices.layer, - FetchHttpClient.layer, - credentialsLayer, - commandRuntimeLayer(["functions", "list"]), + discoveredProjectContextLayer, + provideProjectCommandRuntime( + Layer.mergeAll( + FetchHttpClient.layer, + credentialsLayer, + commandRuntimeLayer(["functions", "list"]), + ), + ), ); export const functionsListCommand = Command.make("list").pipe( diff --git a/apps/cli/src/next/commands/functions/list/list.handler.ts b/apps/cli/src/next/commands/functions/list/list.handler.ts index c0305caaed..053a053dde 100644 --- a/apps/cli/src/next/commands/functions/list/list.handler.ts +++ b/apps/cli/src/next/commands/functions/list/list.handler.ts @@ -1,9 +1,10 @@ import { inferFunctionsManifest, type ResolvedFunctionConfig } from "@supabase/config"; import { makeApiClient } from "@supabase/api/effect"; -import { Effect, Option, Redacted } from "effect"; +import { DateTime, Effect, Option, Redacted } from "effect"; import { CommandRuntime } from "../../../../shared/runtime/command-runtime.service.ts"; import { Credentials } from "../../../auth/credentials.service.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; +import { ProjectContext } from "../../../config/project-context.service.ts"; import { ProjectLinkState } from "../../../config/project-link-state.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; import { Output } from "../../../../shared/output/output.service.ts"; @@ -53,12 +54,10 @@ function formatUtcTimestamp(timestamp: number | undefined): string { return "-"; } - const date = new Date(timestamp); - if (Number.isNaN(date.getTime())) { - return "Invalid date"; - } - - return date.toISOString().replace("T", " ").slice(0, 19); + return Option.match(DateTime.make(timestamp), { + onNone: () => "Invalid date", + onSome: (date) => DateTime.formatIso(date).replace("T", " ").slice(0, 19), + }); } function remoteTextMessage(source: RemoteSource): RemoteTextMessage | undefined { @@ -173,10 +172,14 @@ function mergeInventory( export const functionsList = Effect.fnUntraced(function* () { const output = yield* Output; const runtimeInfo = yield* RuntimeInfo; + const projectContext = yield* ProjectContext; yield* output.intro("List Edge Functions"); - const local = yield* inferFunctionsManifest({ cwd: runtimeInfo.cwd }); + const local = yield* inferFunctionsManifest({ + cwd: runtimeInfo.cwd, + projectEnv: Option.getOrUndefined(projectContext.projectEnv), + }); const remote = yield* loadRemoteInventory(); const functions = mergeInventory(local, remote.functions); const sources = { diff --git a/apps/cli/src/next/commands/functions/list/list.integration.test.ts b/apps/cli/src/next/commands/functions/list/list.integration.test.ts index 63fef9e995..cc4a75453a 100644 --- a/apps/cli/src/next/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/next/commands/functions/list/list.integration.test.ts @@ -1,36 +1,30 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; -import { httpTransportClientLayer } from "@supabase/stack/effect"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option, Stdio } from "effect"; -import { Command } from "effect/unstable/cli"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; +import * as PlatformError from "effect/PlatformError"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { CliConfig } from "../../../config/cli-config.service.ts"; -import { ProjectHome } from "../../../config/project-home.service.ts"; import { InvalidProjectLinkStateError, ProjectLinkState, type ProjectLinkStateValue, } from "../../../config/project-link-state.service.ts"; -import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { - mockAnalytics, mockCredentials, mockOutput, - mockProcessControl, + mockProjectContext, mockProjectLinkState, mockRuntimeInfo, - mockTty, } from "../../../../../tests/helpers/mocks.ts"; -import { functionsCommand } from "../functions.command.ts"; import { functionsList } from "./list.handler.ts"; +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + const PROJECT_REF = "abcdefghijklmnopqrst"; const LINK_STATE: ProjectLinkStateValue = { @@ -49,8 +43,11 @@ const LINK_STATE: ProjectLinkStateValue = { versions: {}, }; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-list-")); +function makeTempDir(): Effect.Effect<string, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "supabase-functions-list-" }); + }).pipe(Effect.provide(BunServices.layer)); } function makeFunction( @@ -92,25 +89,6 @@ function cliConfigLayer() { ); } -function commandTreeSupportLayer(cwd: string) { - const projectHomeDir = join(cwd, ".supabase"); - return Layer.mergeAll( - httpTransportClientLayer, - cliConfigLayer(), - Layer.succeed( - ProjectHome, - ProjectHome.of({ - projectRoot: cwd, - supabaseDir: join(cwd, "supabase"), - projectHomeDir, - projectLinkPath: join(projectHomeDir, "project.json"), - projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), - ensureProjectHomeDir: Effect.void, - }), - ), - ); -} - function jsonResponse( request: HttpClientRequest.HttpClientRequest, status: number, @@ -175,13 +153,37 @@ function mockInvalidProjectLinkState() { ); } -async function writeLocalFunction(cwd: string, slug: string, opts: { denoJson?: boolean } = {}) { - const functionDir = join(cwd, "supabase", "functions", slug); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), "Deno.serve(() => new Response())\n"); - if (opts.denoJson ?? true) { - await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); - } +function writeLocalFunction( + cwd: string, + slug: string, + opts: { denoJson?: boolean } = {}, +): Effect.Effect<void, PlatformError.PlatformError> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(cwd, "supabase", "functions", slug); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString( + path.join(functionDir, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + if (opts.denoJson ?? true) { + yield* fs.writeFileString(path.join(functionDir, "deno.json"), '{"imports":{}}\n'); + } + }).pipe(Effect.provide(BunServices.layer)); +} + +function removeTempDir(tempDir: string): Effect.Effect<void, never> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(tempDir, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer), Effect.orDie); +} + +function withTempDir<A, E, R>( + use: (tempDir: string) => Effect.Effect<A, E, R>, +): Effect.Effect<A, E | PlatformError.PlatformError, R> { + return Effect.acquireUseRelease(makeTempDir(), use, (tempDir) => removeTempDir(tempDir)); } function setup(opts: { @@ -202,6 +204,7 @@ function setup(opts: { BunServices.layer, out.layer, mockRuntimeInfo({ cwd: opts.cwd }), + mockProjectContext(), cliConfigLayer(), mockProjectLinkState(opts.linked ? LINK_STATE : undefined), credentials.layer, @@ -214,300 +217,245 @@ function setup(opts: { describe("functions list", () => { it.live("lists local functions when the project is not linked and does not call the API", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - const { out, layer, api } = setup({ cwd: tempDir, linked: false }); - - yield* functionsList().pipe(Effect.provide(layer)); - - expect(api.requests).toHaveLength(0); - const info = out.messages.filter((message) => message.type === "info").map((m) => m.message); - expect(info.some((message) => message.includes("hello-world"))).toBe(true); - expect(info.some((message) => message.includes("enabled"))).toBe(true); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: "Showing local functions only. Link a project to include deployed functions.", - }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "hello-world"); + const { out, layer, api } = setup({ cwd: tempDir, linked: false }); + + yield* functionsList().pipe(Effect.provide(layer)); + + expect(api.requests).toHaveLength(0); + const info = out.messages + .filter((message) => message.type === "info") + .map((m) => m.message); + expect(info.some((message) => message.includes("hello-world"))).toBe(true); + expect(info.some((message) => message.includes("enabled"))).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: "Showing local functions only. Link a project to include deployed functions.", + }), + ); + }), ); }); it.live("merges local and remote functions by slug in JSON mode", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - const { out, layer, api } = setup({ - cwd: tempDir, - linked: true, - accessToken: "test-token", - format: "json", - remoteFunctions: [ - makeFunction(), - makeFunction({ - id: "remote-only-id", - slug: "remote-only", - name: "Remote Only", - entrypoint_path: "functions/remote-only/index.ts", - import_map_path: "functions/remote-only/deno.json", - }), - ], - }); - - yield* functionsList().pipe(Effect.provide(layer)); - - expect(api.requests).toHaveLength(1); - expect(api.requests[0]?.url).toBe( - "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions", - ); - expect(api.requests[0]?.headers["x-supabase-command"]).toBe("functions list"); - const success = out.messages.find((message) => message.type === "success"); - const data = success?.data as { - functions: Array<{ - slug: string; - local: unknown | null; - remote: { slug: string } | null; - }>; - sources: { remote: { checked: boolean; project_ref?: string } }; - }; - - expect(data.sources.remote).toEqual({ checked: true, project_ref: PROJECT_REF }); - expect(data.functions).toHaveLength(2); - expect(data.functions.find((item) => item.slug === "hello-world")).toMatchObject({ - local: expect.objectContaining({ entrypoint: "./functions/hello-world/index.ts" }), - remote: expect.objectContaining({ slug: "hello-world" }), - }); - expect(data.functions.find((item) => item.slug === "remote-only")).toMatchObject({ - local: null, - remote: expect.objectContaining({ slug: "remote-only" }), - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "hello-world"); + const { out, layer, api } = setup({ + cwd: tempDir, + linked: true, + accessToken: "test-token", + format: "json", + remoteFunctions: [ + makeFunction(), + makeFunction({ + id: "remote-only-id", + slug: "remote-only", + name: "Remote Only", + entrypoint_path: "functions/remote-only/index.ts", + import_map_path: "functions/remote-only/deno.json", + }), + ], + }); + + yield* functionsList().pipe(Effect.provide(layer)); + + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toBe( + "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions", + ); + expect(api.requests[0]?.headers["x-supabase-command"]).toBe("functions list"); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as { + functions: Array<{ + slug: string; + local: object | null; + remote: { slug: string } | null; + }>; + sources: { remote: { checked: boolean; project_ref?: string } }; + }; + + expect(data.sources.remote).toEqual({ checked: true, project_ref: PROJECT_REF }); + expect(data.functions).toHaveLength(2); + expect(data.functions.find((item) => item.slug === "hello-world")).toMatchObject({ + local: expect.objectContaining({ entrypoint: "./functions/hello-world/index.ts" }), + remote: expect.objectContaining({ slug: "hello-world" }), + }); + expect(data.functions.find((item) => item.slug === "remote-only")).toMatchObject({ + local: null, + remote: expect.objectContaining({ slug: "remote-only" }), + }); + }), ); }); it.live("accepts null import_map_path values from the management API", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - const { out, layer } = setup({ - cwd: tempDir, - linked: true, - accessToken: "test-token", - format: "json", - remoteFunctions: [makeFunction({ import_map_path: null })], - }); - - yield* functionsList().pipe(Effect.provide(layer)); - - const success = out.messages.find((message) => message.type === "success"); - const data = success?.data as { - functions: Array<{ - slug: string; - local: unknown | null; - remote: { slug: string; import_map_path?: string | null } | null; - }>; - sources: { remote: { checked: boolean; project_ref?: string; reason?: string } }; - }; - - expect(data.sources.remote).toEqual({ checked: true, project_ref: PROJECT_REF }); - expect(data.functions).toHaveLength(1); - expect(data.functions[0]).toMatchObject({ - slug: "hello-world", - local: expect.objectContaining({ entrypoint: "./functions/hello-world/index.ts" }), - remote: expect.objectContaining({ slug: "hello-world" }), - }); - expect(data.functions[0]?.remote?.import_map_path).toBeNull(); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "hello-world"); + const { out, layer } = setup({ + cwd: tempDir, + linked: true, + accessToken: "test-token", + format: "json", + remoteFunctions: [makeFunction({ import_map_path: null })], + }); + + yield* functionsList().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as { + functions: Array<{ + slug: string; + local: object | null; + remote: { slug: string; import_map_path?: string | null } | null; + }>; + sources: { remote: { checked: boolean; project_ref?: string; reason?: string } }; + }; + + expect(data.sources.remote).toEqual({ checked: true, project_ref: PROJECT_REF }); + expect(data.functions).toHaveLength(1); + expect(data.functions[0]).toMatchObject({ + slug: "hello-world", + local: expect.objectContaining({ entrypoint: "./functions/hello-world/index.ts" }), + remote: expect.objectContaining({ slug: "hello-world" }), + }); + expect(data.functions[0]?.remote?.import_map_path).toBeNull(); + }), ); }); it.live("keeps local-only functions when remote enrichment succeeds", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "local-only")); - const { out, layer } = setup({ - cwd: tempDir, - linked: true, - accessToken: "test-token", - format: "json", - remoteFunctions: [], - }); - - yield* functionsList().pipe(Effect.provide(layer)); - - const success = out.messages.find((message) => message.type === "success"); - const data = success?.data as { - functions: Array<{ slug: string; local: unknown | null; remote: unknown | null }>; - }; - expect(data.functions).toEqual([ - expect.objectContaining({ - slug: "local-only", - local: expect.objectContaining({ entrypoint: "./functions/local-only/index.ts" }), - remote: null, - }), - ]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "local-only"); + const { out, layer } = setup({ + cwd: tempDir, + linked: true, + accessToken: "test-token", + format: "json", + remoteFunctions: [], + }); + + yield* functionsList().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as { + functions: Array<{ slug: string; local: object | null; remote: object | null }>; + }; + expect(data.functions).toEqual([ + expect.objectContaining({ + slug: "local-only", + local: expect.objectContaining({ entrypoint: "./functions/local-only/index.ts" }), + remote: null, + }), + ]); + }), ); }); it.live("fails when the linked project state is invalid", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - const out = mockOutput({ format: "text", interactive: false }); - const api = mockFunctionsApi([]); - const layer = Layer.mergeAll( - BunServices.layer, - out.layer, - mockRuntimeInfo({ cwd: tempDir }), - cliConfigLayer(), - mockInvalidProjectLinkState(), - mockCredentials({ existingToken: "test-token" }).layer, - commandRuntimeLayer(["functions", "list"]), - api.layer, - ); - - const error = yield* functionsList().pipe(Effect.provide(layer), Effect.flip); - - expect(error).toBeInstanceOf(InvalidProjectLinkStateError); - expect(api.requests).toHaveLength(0); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "hello-world"); + const out = mockOutput({ format: "text", interactive: false }); + const api = mockFunctionsApi([]); + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + mockRuntimeInfo({ cwd: tempDir }), + mockProjectContext(), + cliConfigLayer(), + mockInvalidProjectLinkState(), + mockCredentials({ existingToken: "test-token" }).layer, + commandRuntimeLayer(["functions", "list"]), + api.layer, + ); + + const error = yield* functionsList().pipe(Effect.provide(layer), Effect.flip); + + expect(error).toBeInstanceOf(InvalidProjectLinkStateError); + expect(api.requests).toHaveLength(0); + }), ); }); it.live("reports not_authenticated while keeping local inventory", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - const { out, layer, api } = setup({ - cwd: tempDir, - linked: true, - format: "json", - }); - - yield* functionsList().pipe(Effect.provide(layer)); - - expect(api.requests).toHaveLength(0); - const success = out.messages.find((message) => message.type === "success"); - const data = success?.data as { - sources: { remote: { checked: boolean; project_ref?: string; reason?: string } }; - functions: Array<{ slug: string }>; - }; - expect(data.sources.remote).toEqual({ - checked: false, - project_ref: PROJECT_REF, - reason: "not_authenticated", - }); - expect(data.functions).toHaveLength(1); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "hello-world"); + const { out, layer, api } = setup({ + cwd: tempDir, + linked: true, + format: "json", + }); + + yield* functionsList().pipe(Effect.provide(layer)); + + expect(api.requests).toHaveLength(0); + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as { + sources: { remote: { checked: boolean; project_ref?: string; reason?: string } }; + functions: Array<{ slug: string }>; + }; + expect(data.sources.remote).toEqual({ + checked: false, + project_ref: PROJECT_REF, + reason: "not_authenticated", + }); + expect(data.functions).toHaveLength(1); + }), ); }); it.live("reports request_failed while keeping local inventory", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeLocalFunction(tempDir, "hello-world")); - const { out, layer } = setup({ - cwd: tempDir, - linked: true, - accessToken: "test-token", - format: "json", - remoteStatus: 503, - }); - - yield* functionsList().pipe(Effect.provide(layer)); - - const success = out.messages.find((message) => message.type === "success"); - const data = success?.data as { - sources: { remote: { checked: boolean; project_ref?: string; reason?: string } }; - functions: Array<{ slug: string }>; - }; - expect(data.sources.remote).toEqual({ - checked: false, - project_ref: PROJECT_REF, - reason: "request_failed", - }); - expect(data.functions).toHaveLength(1); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir) => + Effect.gen(function* () { + yield* writeLocalFunction(tempDir, "hello-world"); + const { out, layer } = setup({ + cwd: tempDir, + linked: true, + accessToken: "test-token", + format: "json", + remoteStatus: 503, + }); + + yield* functionsList().pipe(Effect.provide(layer)); + + const success = out.messages.find((message) => message.type === "success"); + const data = success?.data as { + sources: { remote: { checked: boolean; project_ref?: string; reason?: string } }; + functions: Array<{ slug: string }>; + }; + expect(data.sources.remote).toEqual({ + checked: false, + project_ref: PROJECT_REF, + reason: "request_failed", + }); + expect(data.functions).toHaveLength(1); + }), ); }); it.live("prints an empty state when no local or remote functions exist", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { out, layer } = setup({ - cwd: tempDir, - linked: true, - accessToken: "test-token", - remoteFunctions: [], - }); - - yield* functionsList().pipe(Effect.provide(layer)); - - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "No Edge Functions found." }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + return withTempDir((tempDir) => + Effect.gen(function* () { + const { out, layer } = setup({ + cwd: tempDir, + linked: true, + accessToken: "test-token", + remoteFunctions: [], + }); - it.live("registers the command under functions list", () => { - const tempDir = makeTempDir(); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const processControl = mockProcessControl(); - const api = mockFunctionsApi([]); - const layer = Layer.mergeAll( - BunServices.layer, - out.layer, - analytics.layer, - processControl.layer, - mockRuntimeInfo({ cwd: tempDir }), - mockTty({ stdinIsTty: false, stdoutIsTty: false }), - commandRuntimeLayer(["functions"]), - commandTreeSupportLayer(tempDir), - mockProjectLinkState(), - mockCredentials().layer, - api.layer, - Stdio.layerTest({ - args: Effect.succeed(["functions", "list"]), - }), - ); + yield* functionsList().pipe(Effect.provide(layer)); - return Effect.gen(function* () { - yield* Command.runWith(functionsCommand, { version: "0.1.0" })(["list"]).pipe( - Effect.provide(layer), - ); - - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "outro", message: "No Edge Functions found." }), - ); - expect(analytics.captured).toContainEqual( - expect.objectContaining({ - event: "cli_command_executed", - properties: expect.objectContaining({ exit_code: 0 }), - }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "outro", message: "No Edge Functions found." }), + ); + }), ); }); }); diff --git a/apps/cli/src/next/commands/functions/new/new.handler.ts b/apps/cli/src/next/commands/functions/new/new.handler.ts index c5c2c1f09c..6055bd28bb 100644 --- a/apps/cli/src/next/commands/functions/new/new.handler.ts +++ b/apps/cli/src/next/commands/functions/new/new.handler.ts @@ -1,4 +1,3 @@ -import { dirname } from "node:path"; import { edgeFunctionDenoConfigFileName, edgeFunctionEntrypointFileName, @@ -62,17 +61,15 @@ function resolveSlug(slug: Option.Option<string>) { return yield* output.promptText("Function name", { validate: validateSlugMessage }); } - return yield* Effect.fail( - new MissingFunctionSlugError({ - detail: "Function name is required in non-interactive mode.", - suggestion: "Pass a function name, for example `supabase functions new hello-world`.", - }), - ); + return yield* new MissingFunctionSlugError({ + detail: "Function name is required in non-interactive mode.", + suggestion: "Pass a function name, for example `supabase functions new hello-world`.", + }); }); } -function projectRootForConfigPath(configPath: string): string { - return dirname(dirname(configPath)); +function projectRootForConfigPath(path: Path.Path, configPath: string): string { + return path.dirname(path.dirname(configPath)); } export const functionsNew = Effect.fnUntraced(function* (slugInput: Option.Option<string>) { @@ -88,18 +85,18 @@ export const functionsNew = Effect.fnUntraced(function* (slugInput: Option.Optio const projectPaths = yield* findProjectPaths(runtimeInfo.cwd); const projectRoot = - projectPaths === null ? runtimeInfo.cwd : projectRootForConfigPath(projectPaths.configPath); + projectPaths === null + ? runtimeInfo.cwd + : projectRootForConfigPath(path, projectPaths.configPath); const functionDir = path.join(projectRoot, "supabase", edgeFunctionsDirectoryName, slug); const entrypointPath = path.join(functionDir, edgeFunctionEntrypointFileName); const denoConfigPath = path.join(functionDir, edgeFunctionDenoConfigFileName); if (yield* fs.exists(entrypointPath)) { - return yield* Effect.fail( - new FunctionEntrypointExistsError({ - detail: `Function entrypoint already exists at ${entrypointPath}.`, - suggestion: "Choose a different function name or remove the existing entrypoint first.", - }), - ); + return yield* new FunctionEntrypointExistsError({ + detail: `Function entrypoint already exists at ${entrypointPath}.`, + suggestion: "Choose a different function name or remove the existing entrypoint first.", + }); } yield* fs.makeDirectory(functionDir, { recursive: true }); diff --git a/apps/cli/src/next/commands/functions/new/new.integration.test.ts b/apps/cli/src/next/commands/functions/new/new.integration.test.ts index 247baae46d..554dba6103 100644 --- a/apps/cli/src/next/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/next/commands/functions/new/new.integration.test.ts @@ -1,11 +1,18 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { httpTransportClientLayer } from "@supabase/stack/effect"; -import { existsSync, mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { + Cause, + ConfigProvider, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, + Stdio, +} from "effect"; import { Command } from "effect/unstable/cli"; import { CliConfig } from "../../../config/cli-config.service.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; @@ -14,6 +21,7 @@ import { mockCredentials, mockOutput, mockProcessControl, + mockProjectContext, mockProjectLinkState, mockRuntimeInfo, mockTty, @@ -21,10 +29,18 @@ import { import { functionsCommand } from "../functions.command.ts"; import { functionsNew } from "./new.handler.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { makeLegacyViperEnvLayer } from "../../../../shared/legacy/legacy-viper-env.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-new-")); -} +const makeTempDir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "supabase-functions-new-" }); +}); + +const removeTempDir = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true }).pipe(Effect.ignore); + }); function buildLayer(cwd: string) { const out = mockOutput({ format: "text", interactive: false }); @@ -35,8 +51,8 @@ function buildLayer(cwd: string) { }; } -function commandTreeSupportLayer(cwd: string) { - const projectHomeDir = join(cwd, ".supabase"); +function commandTreeSupportLayer(cwd: string, path: Path.Path) { + const projectHomeDir = path.join(cwd, ".supabase"); return Layer.mergeAll( httpTransportClientLayer, Layer.succeed( @@ -49,7 +65,7 @@ function commandTreeSupportLayer(cwd: string) { telemetryPosthogKey: Option.some("phc_test_key"), accessToken: Option.none(), noKeyring: Option.none(), - supabaseHome: join(cwd, ".cache", "supabase"), + supabaseHome: path.join(cwd, ".cache", "supabase"), debug: Option.none(), telemetryDebug: Option.none(), telemetryDisabled: Option.none(), @@ -60,10 +76,10 @@ function commandTreeSupportLayer(cwd: string) { ProjectHome, ProjectHome.of({ projectRoot: cwd, - supabaseDir: join(cwd, "supabase"), + supabaseDir: path.join(cwd, "supabase"), projectHomeDir, - projectLinkPath: join(projectHomeDir, "project.json"), - projectLocalVersionsPath: join(projectHomeDir, "local-versions.json"), + projectLinkPath: path.join(projectHomeDir, "project.json"), + projectLocalVersionsPath: path.join(projectHomeDir, "local-versions.json"), ensureProjectHomeDir: Effect.void, }), ), @@ -83,216 +99,234 @@ function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string) { describe("functions new", () => { it.live("creates function files without creating config in an uninitialized project", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer, out } = buildLayer(tempDir); - - yield* functionsNew(Option.some("hello-world")).pipe(Effect.provide(layer)); - - expect(existsSync(join(tempDir, "supabase", "config.json"))).toBe(false); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), - ).toBe(`Deno.serve(async (req) => { + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { layer, out } = buildLayer(tempDir); + + yield* functionsNew(Option.some("hello-world")).pipe(Effect.provide(layer)); + + expect(yield* fs.exists(path.join(tempDir, "supabase", "config.json"))).toBe(false); + expect( + yield* fs.readFileString( + path.join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toBe(`Deno.serve(async (req) => { const { name } = await req.json(); return Response.json({ message: \`Hello \${name}!\` }); }); `); - expect( - JSON.parse( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "deno.json"), "utf8"), - ), - ), - ).toEqual({ - imports: { - "@supabase/functions-js": "jsr:@supabase/functions-js@^2", - }, - }); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Created Edge Function." }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString( + path.join(tempDir, "supabase", "functions", "hello-world", "deno.json"), + ), + ), + ).toEqual({ + imports: { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2", + }, + }); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Created Edge Function." }), + ); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("leaves existing config.json untouched", () => { - const tempDir = makeTempDir(); - const configPath = join(tempDir, "supabase", "config.json"); - const configContent = `${JSON.stringify( - { - $schema: "./node_modules/@supabase/config/schema.json", - db: { major_version: 16 }, - functions: { - existing: { - entrypoint: "./functions/existing/index.ts", - }, - }, - }, - null, - 2, - )}\n`; - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(configPath, configContent)); - const { layer } = buildLayer(tempDir); - - yield* functionsNew(Option.some("hello-world")).pipe(Effect.provide(layer)); - - expect(yield* Effect.tryPromise(() => readFile(configPath, "utf8"))).toBe(configContent); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), - ).toContain("Deno.serve"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(tempDir, "supabase", "config.json"); + const configContent = + '{\n "$schema": "./node_modules/@supabase/config/schema.json",\n "db": {\n "major_version": 16\n },\n "functions": {\n "existing": {\n "entrypoint": "./functions/existing/index.ts"\n }\n }\n}\n'; + + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(configPath, configContent); + const { layer } = buildLayer(tempDir); + + yield* functionsNew(Option.some("hello-world")).pipe(Effect.provide(layer)); + + expect(yield* fs.readFileString(configPath)).toBe(configContent); + expect( + yield* fs.readFileString( + path.join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toContain("Deno.serve"); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("leaves existing config.toml untouched", () => { - const tempDir = makeTempDir(); - const configPath = join(tempDir, "supabase", "config.toml"); - const configContent = 'project_id = "local-ref"\n'; - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(configPath, configContent)); - const { layer } = buildLayer(tempDir); - - yield* functionsNew(Option.some("hello-world")).pipe(Effect.provide(layer)); - - const config = yield* Effect.tryPromise(() => readFile(configPath, "utf8")); - expect(config).toBe(configContent); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), - ).toContain("Deno.serve"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(tempDir, "supabase", "config.toml"); + const configContent = 'project_id = "local-ref"\n'; + + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(configPath, configContent); + const { layer } = buildLayer(tempDir); + + yield* functionsNew(Option.some("hello-world")).pipe(Effect.provide(layer)); + + expect(yield* fs.readFileString(configPath)).toBe(configContent); + expect( + yield* fs.readFileString( + path.join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toContain("Deno.serve"); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("fails when the function entrypoint already exists", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const functionDir = join(tempDir, "supabase", "functions", "hello-world"); - yield* Effect.tryPromise(() => mkdir(functionDir, { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(functionDir, "index.ts"), "// existing\n")); - const { layer } = buildLayer(tempDir); - - const exit = yield* functionsNew(Option.some("hello-world")).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expectFailureTag(exit, "FunctionEntrypointExistsError"); - expect(yield* Effect.tryPromise(() => readFile(join(functionDir, "index.ts"), "utf8"))).toBe( - "// existing\n", - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(tempDir, "supabase", "functions", "hello-world"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString(path.join(functionDir, "index.ts"), "// existing\n"); + const { layer } = buildLayer(tempDir); + + const exit = yield* functionsNew(Option.some("hello-world")).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expectFailureTag(exit, "FunctionEntrypointExistsError"); + expect(yield* fs.readFileString(path.join(functionDir, "index.ts"))).toBe( + "// existing\n", + ); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("rejects invalid slugs", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer } = buildLayer(tempDir); - - const exit = yield* functionsNew(Option.some("hello/world")).pipe( - Effect.provide(layer), - Effect.exit, - ); - - expectFailureTag(exit, "InvalidFunctionSlugError"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + const exit = yield* functionsNew(Option.some("hello/world")).pipe( + Effect.provide(layer), + Effect.exit, + ); + + expectFailureTag(exit, "InvalidFunctionSlugError"); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("prompts for a function slug when interactive text output has no argument", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const out = mockOutput({ - format: "text", - interactive: true, - promptTextResponses: ["hello-world"], - }); - const layer = Layer.mergeAll(out.layer, mockRuntimeInfo({ cwd: tempDir }), BunServices.layer); - - yield* functionsNew(Option.none()).pipe(Effect.provide(layer)); - - expect(existsSync(join(tempDir, "supabase", "config.json"))).toBe(false); - expect( - yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "functions", "hello-world", "index.ts"), "utf8"), - ), - ).toContain("Deno.serve"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const out = mockOutput({ + format: "text", + interactive: true, + promptTextResponses: ["hello-world"], + }); + const layer = Layer.mergeAll( + out.layer, + mockRuntimeInfo({ cwd: tempDir }), + BunServices.layer, + ); + + yield* functionsNew(Option.none()).pipe(Effect.provide(layer)); + + expect(yield* fs.exists(path.join(tempDir, "supabase", "config.json"))).toBe(false); + expect( + yield* fs.readFileString( + path.join(tempDir, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toContain("Deno.serve"); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("fails without a function slug in non-interactive mode", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer } = buildLayer(tempDir); + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const { layer } = buildLayer(tempDir); - const exit = yield* functionsNew(Option.none()).pipe(Effect.provide(layer), Effect.exit); + const exit = yield* functionsNew(Option.none()).pipe(Effect.provide(layer), Effect.exit); - expectFailureTag(exit, "MissingFunctionSlugError"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expectFailureTag(exit, "MissingFunctionSlugError"); + }), + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); it.live("registers the command under functions new", () => { - const tempDir = makeTempDir(); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockAnalytics(); - const processControl = mockProcessControl(); - const layer = Layer.mergeAll( - out.layer, - analytics.layer, - processControl.layer, - mockRuntimeInfo({ cwd: tempDir }), - mockTty({ stdinIsTty: false, stdoutIsTty: false }), - commandRuntimeLayer(["functions"]), - BunServices.layer, - commandTreeSupportLayer(tempDir), - mockProjectLinkState(), - mockCredentials().layer, - Stdio.layerTest({ - args: Effect.succeed(["functions", "new", "hello-world"]), - }), - ); - - return Effect.gen(function* () { - yield* Command.runWith(functionsCommand, { version: "0.1.0" })(["new", "hello-world"]).pipe( - Effect.provide(layer), - ); - - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Created Edge Function." }), - ); - expect(analytics.captured).toContainEqual( - expect.objectContaining({ - event: "cli_command_executed", - properties: expect.objectContaining({ exit_code: 0 }), + return Effect.acquireUseRelease( + makeTempDir, + (tempDir) => + Effect.gen(function* () { + const path = yield* Path.Path; + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + out.layer, + analytics.layer, + processControl.layer, + mockRuntimeInfo({ cwd: tempDir }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + commandRuntimeLayer(["functions"]).pipe(Layer.provide(BunServices.layer)), + commandTreeSupportLayer(tempDir, path), + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true }), + ), + mockProjectContext(), + mockProjectLinkState(), + mockCredentials().layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "new", "hello-world"]), + }), + ); + + yield* Command.runWith(functionsCommand, { version: "0.1.0" })([ + "new", + "hello-world", + ]).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Created Edge Function." }), + ); + expect(analytics.captured).toContainEqual( + expect.objectContaining({ + event: "cli_command_executed", + properties: expect.objectContaining({ exit_code: 0 }), + }), + ); }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + removeTempDir, + ).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/commands/init/init.e2e.test.ts b/apps/cli/src/next/commands/init/init.e2e.test.ts index 9b9c45f88a..21d16d6e57 100644 --- a/apps/cli/src/next/commands/init/init.e2e.test.ts +++ b/apps/cli/src/next/commands/init/init.e2e.test.ts @@ -1,25 +1,29 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, test } from "vitest"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; const INIT_TIMEOUT_MS = 5_000; describe("supabase init", () => { - test("creates config.toml in the current directory", { timeout: INIT_TIMEOUT_MS }, async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-init-e2e-")); + test("creates config.toml in the current directory", { timeout: INIT_TIMEOUT_MS }, () => + Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-init-e2e-" }); + const { stdout, exitCode } = yield* Effect.tryPromise(() => + runSupabase(["init"], { cwd: tempDir }), + ); - try { - const { stdout, exitCode } = await runSupabase(["init"], { cwd: tempDir }); + expect(exitCode).toBe(0); + expect(stdout).toContain("Initialized Supabase project."); - expect(exitCode).toBe(0); - expect(stdout).toContain("Initialized Supabase project."); - - const content = await readFile(join(tempDir, "supabase", "config.toml"), "utf8"); - expect(content).toContain("major_version = 17"); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); + const content = yield* fs.readFileString(path.join(tempDir, "supabase", "config.toml")); + expect(content).toContain("major_version = 17"); + }), + ).pipe(Effect.provide(BunServices.layer)), + ), + ); }); diff --git a/apps/cli/src/next/commands/init/init.handler.ts b/apps/cli/src/next/commands/init/init.handler.ts index 172ca087d6..7a7d598853 100644 --- a/apps/cli/src/next/commands/init/init.handler.ts +++ b/apps/cli/src/next/commands/init/init.handler.ts @@ -13,12 +13,10 @@ export const init = Effect.fnUntraced(function* ( const runtimeInfo = yield* RuntimeInfo; if (flags.useOrioledb && !flags.experimental) { - return yield* Effect.fail( - new InitExperimentalRequiredError({ - detail: "--use-orioledb is only available when experimental features are enabled.", - suggestion: "Rerun the command with `supabase init --experimental --use-orioledb`.", - }), - ); + return yield* new InitExperimentalRequiredError({ + detail: "--use-orioledb is only available when experimental features are enabled.", + suggestion: "Rerun the command with `supabase init --experimental --use-orioledb`.", + }); } yield* output.intro("Initialize local Supabase project"); diff --git a/apps/cli/src/next/commands/init/init.integration.test.ts b/apps/cli/src/next/commands/init/init.integration.test.ts index b518782af2..d014595a2a 100644 --- a/apps/cli/src/next/commands/init/init.integration.test.ts +++ b/apps/cli/src/next/commands/init/init.integration.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option, Stdio } from "effect"; +import { Cause, Effect, FileSystem, Exit, Layer, Option, Path, Schema, Stdio } from "effect"; import { Command } from "effect/unstable/cli"; import { INIT_GITIGNORE_TEMPLATE } from "../../../shared/init/project-init.templates.ts"; import { CurrentAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; @@ -19,8 +15,22 @@ import { import { initCommand } from "./init.command.ts"; import { init } from "./init.handler.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-init-command-")); +const decodeJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function withTempDir<A, E>( + body: (tempDir: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, E, never>, +) { + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-init-command-" }); + return yield* body(tempDir, fs, path); + }).pipe(Effect.provide(BunServices.layer)), + ); } function buildLayer( @@ -100,228 +110,199 @@ function expectFailureTag(exit: Exit.Exit<unknown, unknown>, tag: string): Recor describe("init handler", () => { it.live("creates config.toml and supabase/.gitignore", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - const { layer, out } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - const configPath = join(tempDir, "supabase", "config.toml"); - const content = yield* Effect.tryPromise(() => readFile(configPath, "utf8")); - - expect(content).toContain(`project_id = "${basename(tempDir)}"`); - expect(content).toContain("major_version = 17"); - expect(content).toContain('orioledb_version = ""'); - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, "supabase", ".gitignore"), "utf8")), - ).toBe(INIT_GITIGNORE_TEMPLATE); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Initialized Supabase project.", - data: expect.objectContaining({ config_path: configPath, created: true }), - }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(tempDir, ".git"), { recursive: true }); + const { layer, out } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + const configPath = path.join(tempDir, "supabase", "config.toml"); + const content = yield* fs.readFileString(configPath); + + expect(content).toContain(`project_id = "${path.basename(tempDir)}"`); + expect(content).toContain("major_version = 17"); + expect(content).toContain('orioledb_version = ""'); + expect(yield* fs.readFileString(path.join(tempDir, "supabase", ".gitignore"))).toBe( + INIT_GITIGNORE_TEMPLATE, + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Initialized Supabase project.", + data: expect.objectContaining({ config_path: configPath, created: true }), + }), + ); + }), ); }); it.live("reports an already-initialized project without overwriting it", () => { - const tempDir = makeTempDir(); - const configPath = join(tempDir, "supabase", "config.toml"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(configPath, 'project_id = "existing"\n')); - const { layer, out } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Supabase project already initialized.", - data: expect.objectContaining({ config_path: configPath, created: false }), - }), - ); - expect(yield* Effect.tryPromise(() => readFile(configPath, "utf8"))).toBe( - 'project_id = "existing"\n', - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const configPath = path.join(tempDir, "supabase", "config.toml"); + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(configPath, 'project_id = "existing"\n'); + const { layer, out } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Supabase project already initialized.", + data: expect.objectContaining({ config_path: configPath, created: false }), + }), + ); + expect(yield* fs.readFileString(configPath)).toBe('project_id = "existing"\n'); + }), ); }); it.live("ignores a legacy config.json when creating config.toml", () => { - const tempDir = makeTempDir(); - const jsonPath = join(tempDir, "supabase", "config.json"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(jsonPath, '{ "$schema": "./schema.json" }\n')); - const { layer } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, "supabase", "config.toml"), "utf8")), - ).toContain(`project_id = "${basename(tempDir)}"`); - expect(yield* Effect.tryPromise(() => readFile(jsonPath, "utf8"))).toBe( - '{ "$schema": "./schema.json" }\n', - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const jsonPath = path.join(tempDir, "supabase", "config.json"); + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(jsonPath, '{ "$schema": "./schema.json" }\n'); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(yield* fs.readFileString(path.join(tempDir, "supabase", "config.toml"))).toContain( + `project_id = "${path.basename(tempDir)}"`, + ); + expect(yield* fs.readFileString(jsonPath)).toBe('{ "$schema": "./schema.json" }\n'); + }), ); }); it.live("does not remove a legacy config.json when force is set", () => { - const tempDir = makeTempDir(); - const jsonPath = join(tempDir, "supabase", "config.json"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(jsonPath, '{ "$schema": "./schema.json" }\n')); - const { layer } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: true, - }).pipe(Effect.provide(layer)); - - const content = yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "config.toml"), "utf8"), - ); - expect(content).toContain(`project_id = "${basename(tempDir)}"`); - expect(yield* Effect.tryPromise(() => readFile(jsonPath, "utf8"))).toBe( - '{ "$schema": "./schema.json" }\n', - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const jsonPath = path.join(tempDir, "supabase", "config.json"); + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(jsonPath, '{ "$schema": "./schema.json" }\n'); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: true, + }).pipe(Effect.provide(layer)); + + const content = yield* fs.readFileString(path.join(tempDir, "supabase", "config.toml")); + expect(content).toContain(`project_id = "${path.basename(tempDir)}"`); + expect(yield* fs.readFileString(jsonPath)).toBe('{ "$schema": "./schema.json" }\n'); + }), ); }); it.live("writes the OrioleDB version when requested", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer } = buildLayer(tempDir); - - yield* init({ interactive: false, experimental: true, useOrioledb: true, force: false }).pipe( - Effect.provide(layer), - ); + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: true, + useOrioledb: true, + force: false, + }).pipe(Effect.provide(layer)); - const content = yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "config.toml"), "utf8"), - ); - expect(content).toContain('orioledb_version = "15.1.0.150"'); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + const content = yield* fs.readFileString(path.join(tempDir, "supabase", "config.toml")); + expect(content).toContain('orioledb_version = "15.1.0.150"'); + }), ); }); it.live("prompts for IDE settings in interactive mode", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer, out } = buildLayer(tempDir, { - interactive: true, - stdinIsTty: true, - promptConfirmResponses: [true], - }); + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const { layer, out } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); - yield* init({ - interactive: true, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - expect( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), - ).toContain('"deno.enablePaths"'); - expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(yield* fs.readFileString(path.join(tempDir, ".vscode", "settings.json"))).toContain( + '"deno.enablePaths"', + ); + expect(out.stdoutText).toContain("Generated VS Code settings in .vscode/settings.json."); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + ); + }), ); }); it.live("overwrites nested VS Code formatter settings the same way as the old init flow", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(tempDir, ".vscode", "settings.json"), - JSON.stringify( - { - custom: true, - "[typescript]": { - "editor.tabSize": 4, - }, + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + yield* fs.makeDirectory(path.join(tempDir, ".vscode"), { recursive: true }); + yield* fs.writeFileString( + path.join(tempDir, ".vscode", "settings.json"), + encodeJson({ + custom: true, + "[typescript]": { + "editor.tabSize": 4, }, - null, - 2, - ), - ), - ); - const { layer } = buildLayer(tempDir, { - interactive: true, - stdinIsTty: true, - promptConfirmResponses: [true], - }); + }), + ); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); - yield* init({ - interactive: true, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); - const settings = JSON.parse( - yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), - ) as Record<string, unknown>; + const settings = yield* decodeJson( + yield* fs.readFileString(path.join(tempDir, ".vscode", "settings.json")), + ); - expect(settings.custom).toBe(true); - expect(settings["[typescript]"]).toEqual({ - "editor.defaultFormatter": "denoland.vscode-deno", - }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + expect(settings.custom).toBe(true); + expect(settings["[typescript]"]).toEqual({ + "editor.defaultFormatter": "denoland.vscode-deno", + }); + }), ); }); it.live("merges into a JSONC settings file with comments and trailing commas", () => { - const tempDir = makeTempDir(); - const settingsPath = join(tempDir, ".vscode", "settings.json"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const settingsPath = path.join(tempDir, ".vscode", "settings.json"); + yield* fs.makeDirectory(path.join(tempDir, ".vscode"), { recursive: true }); + yield* fs.writeFileString( settingsPath, [ "{", @@ -331,269 +312,229 @@ describe("init handler", () => { ' "files.eol": "\\n",', "}", ].join("\n"), - ), - ); - const { layer } = buildLayer(tempDir, { - interactive: true, - stdinIsTty: true, - promptConfirmResponses: [true], - }); - - yield* init({ - interactive: true, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - const settings = JSON.parse( - yield* Effect.tryPromise(() => readFile(settingsPath, "utf8")), - ) as Record<string, unknown>; - - expect(settings["editor.tabSize"]).toBe(4); - expect(settings["files.eol"]).toBe("\n"); - expect(settings["deno.enablePaths"]).toBeDefined(); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live( - "fails with InitParseSettingsError on a malformed settings file without clobbering it", - () => { - const tempDir = makeTempDir(); - const settingsPath = join(tempDir, ".vscode", "settings.json"); - const malformed = '{ "editor.tabSize": '; - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".vscode"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(settingsPath, malformed)); + ); const { layer } = buildLayer(tempDir, { interactive: true, stdinIsTty: true, promptConfirmResponses: [true], }); - const exit = yield* init({ + yield* init({ interactive: true, experimental: false, useOrioledb: false, force: false, - }).pipe(Effect.provide(layer), Effect.exit); + }).pipe(Effect.provide(layer)); + + const settings = yield* decodeJson(yield* fs.readFileString(settingsPath)); + + expect(settings["editor.tabSize"]).toBe(4); + expect(settings["files.eol"]).toBe("\n"); + expect(settings["deno.enablePaths"]).toBeDefined(); + }), + ); + }); - expectFailureTag(exit, "InitParseSettingsError"); - expect(yield* Effect.tryPromise(() => readFile(settingsPath, "utf8"))).toBe(malformed); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + it.live( + "fails with InitParseSettingsError on a malformed settings file without clobbering it", + () => { + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const settingsPath = path.join(tempDir, ".vscode", "settings.json"); + const malformed = '{ "editor.tabSize": '; + yield* fs.makeDirectory(path.join(tempDir, ".vscode"), { recursive: true }); + yield* fs.writeFileString(settingsPath, malformed); + const { layer } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: true, + promptConfirmResponses: [true], + }); + + const exit = yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer), Effect.exit); + + expectFailureTag(exit, "InitParseSettingsError"); + expect(yield* fs.readFileString(settingsPath)).toBe(malformed); + }), ); }, ); it.live("does not prompt for IDE settings when stdin is not a TTY", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer, out } = buildLayer(tempDir, { - interactive: true, - stdinIsTty: false, - promptConfirmResponses: [true], - }); + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const { layer, out } = buildLayer(tempDir, { + interactive: true, + stdinIsTty: false, + promptConfirmResponses: [true], + }); - yield* init({ - interactive: true, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); + yield* init({ + interactive: true, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), - ); - expect(out.stdoutText).not.toContain("Generated VS Code settings"); - expect( - yield* Effect.tryPromise(async () => { - try { - await readFile(join(tempDir, ".vscode", "settings.json"), "utf8"); - return true; - } catch { - return false; - } - }), - ).toBe(false); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Initialized Supabase project." }), + ); + expect(out.stdoutText).not.toContain("Generated VS Code settings"); + expect(yield* fs.exists(path.join(tempDir, ".vscode", "settings.json"))).toBe(false); + }), ); }); it.live("only writes supabase/.gitignore inside a git repo", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - expect( - yield* Effect.tryPromise(async () => { - try { - await readFile(join(tempDir, "supabase", ".gitignore"), "utf8"); - return true; - } catch { - return false; - } - }), - ).toBe(false); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(yield* fs.exists(path.join(tempDir, "supabase", ".gitignore"))).toBe(false); + }), ); }); it.live("appends to an existing supabase/.gitignore without clobbering it", () => { - const tempDir = makeTempDir(); - const gitignorePath = join(tempDir, "supabase", ".gitignore"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(gitignorePath, "existing-entry\n")); - const { layer } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - expect(yield* Effect.tryPromise(() => readFile(gitignorePath, "utf8"))).toBe( - `existing-entry\n\n${INIT_GITIGNORE_TEMPLATE}`, - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const gitignorePath = path.join(tempDir, "supabase", ".gitignore"); + yield* fs.makeDirectory(path.join(tempDir, ".git"), { recursive: true }); + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(gitignorePath, "existing-entry\n"); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + expect(yield* fs.readFileString(gitignorePath)).toBe( + `existing-entry\n\n${INIT_GITIGNORE_TEMPLATE}`, + ); + }), ); }); it.live("prepends a line break even when the existing supabase/.gitignore is empty", () => { - const tempDir = makeTempDir(); - const gitignorePath = join(tempDir, "supabase", ".gitignore"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(tempDir, ".git"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(tempDir, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(gitignorePath, "")); - const { layer } = buildLayer(tempDir); - - yield* init({ - interactive: false, - experimental: false, - useOrioledb: false, - force: false, - }).pipe(Effect.provide(layer)); - - // Go appends `\n` + template to any pre-existing file, even an empty one - // (`apps/cli-go/internal/init/init.go:80-96`, deleted in CLI-1970; last - // present at commit 7b469f5b3). - expect(yield* Effect.tryPromise(() => readFile(gitignorePath, "utf8"))).toBe( - `\n${INIT_GITIGNORE_TEMPLATE}`, - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + return withTempDir((tempDir, fs, path) => + Effect.gen(function* () { + const gitignorePath = path.join(tempDir, "supabase", ".gitignore"); + yield* fs.makeDirectory(path.join(tempDir, ".git"), { recursive: true }); + yield* fs.makeDirectory(path.join(tempDir, "supabase"), { recursive: true }); + yield* fs.writeFileString(gitignorePath, ""); + const { layer } = buildLayer(tempDir); + + yield* init({ + interactive: false, + experimental: false, + useOrioledb: false, + force: false, + }).pipe(Effect.provide(layer)); + + // Go appends `\n` + template to any pre-existing file, even an empty one + // (`apps/cli-go/internal/init/init.go:80-96`, deleted in CLI-1970; last + // present at commit 7b469f5b3). + expect(yield* fs.readFileString(gitignorePath)).toBe(`\n${INIT_GITIGNORE_TEMPLATE}`); + }), ); }); it.live("requires --experimental when --use-orioledb is set", () => { - const tempDir = makeTempDir(); - - return Effect.gen(function* () { - const { layer } = buildLayer(tempDir); - - const exit = yield* init({ - interactive: false, - experimental: false, - useOrioledb: true, - force: false, - }).pipe(Effect.provide(layer), Effect.exit); - - // The next shell deliberately keeps this friendlier wording; the legacy - // shell matches Go's cobra message instead (CLI-1986). - const error = expectFailureTag(exit, "InitExperimentalRequiredError"); - expect(error["message"]).toBe("The --use-orioledb flag requires --experimental."); - expect(error["suggestion"]).toBe( - "Rerun the command with `supabase init --experimental --use-orioledb`.", - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); + return withTempDir((tempDir) => + Effect.gen(function* () { + const { layer } = buildLayer(tempDir); - it.live("emits a canonical command event with no default flag values", () => { - const tempDir = makeTempDir(); - const runtimeInfoLayer = mockRuntimeInfo({ cwd: tempDir }); - const processControl = mockProcessControl(); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockContextualAnalytics(); - const layer = Layer.mergeAll( - BunServices.layer, - out.layer, - analytics.layer, - runtimeInfoLayer, - processControl.layer, - mockTty(), - Stdio.layerTest({ - args: Effect.succeed(["init"]), + const exit = yield* init({ + interactive: false, + experimental: false, + useOrioledb: true, + force: false, + }).pipe(Effect.provide(layer), Effect.exit); + + // The next shell deliberately keeps this friendlier wording; the legacy + // shell matches Go's cobra message instead (CLI-1986). + const error = expectFailureTag(exit, "InitExperimentalRequiredError"); + expect(error["message"]).toBe("The --use-orioledb flag requires --experimental."); + expect(error["suggestion"]).toBe( + "Rerun the command with `supabase init --experimental --use-orioledb`.", + ); }), ); + }); - return Effect.gen(function* () { - yield* Command.runWith(initCommand, { version: "0.1.0" })([]).pipe(Effect.provide(layer)); - - expect(analytics.captured).toHaveLength(1); - expect(analytics.captured[0]).toEqual({ - event: "cli_command_executed", - properties: expect.objectContaining({ - command: "init", - flags_used: [], - flag_values: {}, - exit_code: 0, + it.live("emits a canonical command event with no default flag values", () => { + return withTempDir((tempDir) => { + const runtimeInfoLayer = mockRuntimeInfo({ cwd: tempDir }); + const processControl = mockProcessControl(); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockContextualAnalytics(); + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + analytics.layer, + runtimeInfoLayer, + processControl.layer, + mockTty(), + Stdio.layerTest({ + args: Effect.succeed(["init"]), }), + ); + + return Effect.gen(function* () { + yield* Command.runWith(initCommand, { version: "0.1.0" })([]).pipe(Effect.provide(layer)); + + expect(analytics.captured).toHaveLength(1); + expect(analytics.captured[0]).toEqual({ + event: "cli_command_executed", + properties: expect.objectContaining({ + command: "init", + flags_used: [], + flag_values: {}, + exit_code: 0, + }), + }); }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }); }); it.live("wires command flags through the parser", () => { - const tempDir = makeTempDir(); - const runtimeInfoLayer = mockRuntimeInfo({ cwd: tempDir }); - const out = mockOutput({ format: "text", interactive: false }); - const analytics = mockContextualAnalytics(); - const processControl = mockProcessControl(); - const layer = Layer.mergeAll( - BunServices.layer, - out.layer, - analytics.layer, - runtimeInfoLayer, - mockTty(), - processControl.layer, - ); + return withTempDir((tempDir, fs, path) => { + const runtimeInfoLayer = mockRuntimeInfo({ cwd: tempDir }); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockContextualAnalytics(); + const processControl = mockProcessControl(); + const layer = Layer.mergeAll( + BunServices.layer, + out.layer, + analytics.layer, + runtimeInfoLayer, + mockTty(), + processControl.layer, + ); - return Effect.gen(function* () { - yield* Command.runWith(initCommand, { version: "0.1.0" })([ - "--experimental", - "--use-orioledb", - ]).pipe(Effect.provide(layer)); + return Effect.gen(function* () { + yield* Command.runWith(initCommand, { version: "0.1.0" })([ + "--experimental", + "--use-orioledb", + ]).pipe(Effect.provide(layer)); - const content = yield* Effect.tryPromise(() => - readFile(join(tempDir, "supabase", "config.toml"), "utf8"), - ); - expect(content).toContain('orioledb_version = "15.1.0.150"'); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const content = yield* fs.readFileString(path.join(tempDir, "supabase", "config.toml")); + expect(content).toContain('orioledb_version = "15.1.0.150"'); + }); + }); }); }); diff --git a/apps/cli/src/next/commands/issue/issue.handler.ts b/apps/cli/src/next/commands/issue/issue.handler.ts index dee668f956..f67c88b289 100644 --- a/apps/cli/src/next/commands/issue/issue.handler.ts +++ b/apps/cli/src/next/commands/issue/issue.handler.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Config, Effect, Option } from "effect"; import { Browser } from "../../../shared/runtime/browser.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; @@ -24,9 +24,19 @@ const openIssueUrl = Effect.fnUntraced(function* (url: string, noBrowser: boolea } }); +const issueInstallEnvironment = Effect.gen(function* () { + const installMethod = yield* Config.option(Config.string("SUPABASE_INSTALL_METHOD")); + const userAgent = yield* Config.option(Config.string("npm_config_user_agent")); + return { + SUPABASE_INSTALL_METHOD: Option.getOrUndefined(installMethod), + npm_config_user_agent: Option.getOrUndefined(userAgent), + }; +}); + export const openBugIssue = Effect.fn("issue.bug")(function* (flags: BugIssueFlags) { const runtimeInfo = yield* RuntimeInfo; const telemetryRuntime = yield* TelemetryRuntime; + const environment = yield* issueInstallEnvironment; const url = buildIssueUrl({ template: issueTemplateContract.bug.template, @@ -34,7 +44,7 @@ export const openBugIssue = Effect.fn("issue.bug")(function* (flags: BugIssueFla "affected-area": readIssueFlagValue(flags.area), "cli-version": telemetryRuntime.cliVersion, os: `${runtimeInfo.platform} ${runtimeInfo.arch}`, - "install-method": inferIssueInstallMethod(runtimeInfo), + "install-method": inferIssueInstallMethod(runtimeInfo, environment), command: readIssueFlagValue(flags.command), "actual-output": readIssueFlagValue(flags.actualOutput), "expected-behavior": readIssueFlagValue(flags.expectedBehavior), diff --git a/apps/cli/src/next/commands/issue/issue.integration.test.ts b/apps/cli/src/next/commands/issue/issue.integration.test.ts index abc8853c5f..5ca33bdc7a 100644 --- a/apps/cli/src/next/commands/issue/issue.integration.test.ts +++ b/apps/cli/src/next/commands/issue/issue.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import type { OutputFormat } from "../../../shared/output/types.ts"; import { Browser } from "../../../shared/runtime/browser.service.ts"; @@ -16,29 +16,11 @@ type OutputMessage = { }; function processEnvLayer(values: Readonly<Record<string, string | undefined>> = {}) { - return Layer.effectDiscard( - Effect.acquireRelease( - Effect.sync(() => { - const snapshot = { ...process.env }; - for (const key of Object.keys(process.env)) { - delete process.env[key]; - } - for (const [key, value] of Object.entries(values)) { - if (value !== undefined) process.env[key] = value; - } - return snapshot; - }), - (snapshot) => - Effect.sync(() => { - for (const key of Object.keys(process.env)) { - delete process.env[key]; - } - for (const [key, value] of Object.entries(snapshot)) { - if (value !== undefined) process.env[key] = value; - } - }), - ), - ); + const env: Record<string, string> = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) env[key] = value; + } + return ConfigProvider.layer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })); } function mockOutput(opts: { readonly format?: OutputFormat } = {}) { diff --git a/apps/cli/src/next/commands/link/link.command.ts b/apps/cli/src/next/commands/link/link.command.ts index 58ce99e91a..71992678be 100644 --- a/apps/cli/src/next/commands/link/link.command.ts +++ b/apps/cli/src/next/commands/link/link.command.ts @@ -19,14 +19,14 @@ const flags = { export type LinkFlags = CliCommand.Command.Config.Infer<typeof flags>; -const linkPlatformApiLayer = platformApiLayer.pipe(Layer.provide(credentialsLayer)); +const linkCommandRuntimeLayer = commandRuntimeLayer(["link"]); +const linkPlatformApiLayer = platformApiLayer.pipe( + Layer.provide(credentialsLayer), + Layer.provide(linkCommandRuntimeLayer), +); const linkProjectLinkRemoteLayer = projectLinkRemoteLayer.pipe(Layer.provide(linkPlatformApiLayer)); -const linkRuntimeLayer = Layer.mergeAll( - linkProjectLinkRemoteLayer, - projectLinkStateLayer, - commandRuntimeLayer(["link"]), -); +const linkRuntimeLayer = Layer.mergeAll(linkProjectLinkRemoteLayer, projectLinkStateLayer); export const linkCommand = Command.make("link", flags).pipe( Command.withDescription( diff --git a/apps/cli/src/next/commands/link/link.e2e.test.ts b/apps/cli/src/next/commands/link/link.e2e.test.ts index 196abe11cf..3cb72cf22f 100644 --- a/apps/cli/src/next/commands/link/link.e2e.test.ts +++ b/apps/cli/src/next/commands/link/link.e2e.test.ts @@ -1,35 +1,59 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as EffectPath from "effect/Path"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, test } from "vitest"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; -const LINK_TIMEOUT_MS = 5_000; +const { join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); -describe("supabase link", () => { - test( - "fails with platform auth error instead of root fallback services", - { timeout: LINK_TIMEOUT_MS }, - async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-link-e2e-")); - const projectRoot = join(tempDir, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile(join(projectRoot, "supabase", "config.toml"), "# test project\n"); - - const { stdout, stderr, exitCode } = await runSupabase( - ["link", "--project-ref", "abcdefghijklmnopqrst"], - { cwd: projectRoot }, - ); - - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("You are not logged in to Supabase."); - expect(`${stdout}${stderr}`).not.toContain("unexpected root credentials access"); - expect(`${stdout}${stderr}`).not.toContain("unexpected root platform api client access"); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }, +const withFileSystem = <A>( + effect: Effect.Effect<A, PlatformError, FileSystem.FileSystem>, +): Effect.Effect<A, PlatformError, never> => effect.pipe(Effect.provide(BunFileSystem.layer)); + +const mkdir = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }), + ); + +const writeFile = (path: string, content: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, content); + }), + ); + +const rm = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true, force: true }); + }), ); + +describe("supabase link", () => { + it.live("fails with platform auth error instead of root fallback services", () => { + const tempDir = join(tmpdir(), `supabase-link-e2e-${randomUUID()}`); + const projectRoot = join(tempDir, "repo"); + + return Effect.gen(function* () { + yield* mkdir(join(projectRoot, "supabase")); + yield* writeFile(join(projectRoot, "supabase", "config.toml"), "# test project\n"); + + const { stdout, stderr, exitCode } = yield* Effect.tryPromise(() => + runSupabase(["link", "--project-ref", "abcdefghijklmnopqrst"], { cwd: projectRoot }), + ); + + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("You are not logged in to Supabase."); + expect(`${stdout}${stderr}`).not.toContain("unexpected root credentials access"); + expect(`${stdout}${stderr}`).not.toContain("unexpected root platform api client access"); + }).pipe(Effect.ensuring(rm(tempDir).pipe(Effect.orDie)), Effect.provide(BunServices.layer)); + }); }); diff --git a/apps/cli/src/next/commands/link/link.handler.ts b/apps/cli/src/next/commands/link/link.handler.ts index d7f7a0bdd0..2f970df6cf 100644 --- a/apps/cli/src/next/commands/link/link.handler.ts +++ b/apps/cli/src/next/commands/link/link.handler.ts @@ -21,12 +21,10 @@ const promptForAccessibleProject = Effect.fnUntraced(function* () { const remote = yield* ProjectLinkRemote; const projects = yield* remote.listAccessibleProjects; if (projects.length === 0) { - return yield* Effect.fail( - new NoAccessibleProjectsError({ - detail: "No accessible Supabase projects were found for this account.", - suggestion: "Create a project in the dashboard or log in with a different account.", - }), - ); + return yield* new NoAccessibleProjectsError({ + detail: "No accessible Supabase projects were found for this account.", + suggestion: "Create a project in the dashboard or log in with a different account.", + }); } return yield* output.promptSelect( @@ -89,12 +87,10 @@ const chooseProjectRef = Effect.fnUntraced(function* (flagProjectRef: Option.Opt } if (!output.interactive) { - return yield* Effect.fail( - new ProjectRefRequiredError({ - detail: "A project ref is required in non-interactive mode.", - suggestion: "Pass --project-ref or link this checkout interactively first.", - }), - ); + return yield* new ProjectRefRequiredError({ + detail: "A project ref is required in non-interactive mode.", + suggestion: "Pass --project-ref or link this checkout interactively first.", + }); } return yield* promptForAccessibleProject(); diff --git a/apps/cli/src/next/commands/link/link.integration.test.ts b/apps/cli/src/next/commands/link/link.integration.test.ts index 5af28a05f9..ec3efc6d9e 100644 --- a/apps/cli/src/next/commands/link/link.integration.test.ts +++ b/apps/cli/src/next/commands/link/link.integration.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { execFile } from "node:child_process"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { BunFileSystem, BunPath, BunServices } from "@effect/platform-bun"; +import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as EffectPath from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import { Data } from "effect"; import { mockAnalytics, mockOutput, @@ -21,22 +23,74 @@ import { ProjectLinkState } from "../../config/project-link-state.service.ts"; import { NoAccessibleProjectsError, ProjectRefRequiredError } from "./link.errors.ts"; import { link } from "./link.handler.ts"; +const { join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); + function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-link-command-")); + return join(tmpdir(), `supabase-link-command-${randomUUID()}`); } -const runGit = (cwd: string, args: ReadonlyArray<string>): Promise<void> => - new Promise((resolve, reject) => { - execFile("git", args, { cwd }, (error) => (error === null ? resolve() : reject(error))); - }); +class GitCommandFailedError extends Data.TaggedError("GitCommandFailedError")<{ + readonly cwd: string; + readonly args: ReadonlyArray<string>; + readonly exitCode: number; +}> {} + +const withFileSystem = <A>( + effect: Effect.Effect<A, PlatformError, FileSystem.FileSystem>, +): Effect.Effect<A, PlatformError, never> => effect.pipe(Effect.provide(BunFileSystem.layer)); + +const mkdir = (path: string, options?: { readonly recursive?: boolean }) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, options); + }), + ); + +const readFile = (path: string, _encoding?: "utf8") => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path); + }), + ); + +const writeFile = (path: string, content: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, content); + }), + ); -const initializeRepository = async (projectRoot: string): Promise<void> => { - await mkdir(projectRoot, { recursive: true }); - await runGit(projectRoot, ["init", "--initial-branch=main"]); - await runGit(projectRoot, ["config", "user.email", "stack-tests@supabase.local"]); - await runGit(projectRoot, ["config", "user.name", "Stack Tests"]); - await runGit(projectRoot, ["commit", "--allow-empty", "-m", "initial"]); -}; +const rm = (path: string, options?: { readonly recursive?: boolean; readonly force?: boolean }) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, options); + }), + ); + +const cleanupTempDir = (path: string) => + rm(path, { recursive: true, force: true }).pipe(Effect.orDie); + +const runGit = (cwd: string, args: ReadonlyArray<string>) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const exitCode = yield* spawner.exitCode(ChildProcess.make("git", args, { cwd })); + if (Number(exitCode) !== 0) { + return yield* new GitCommandFailedError({ cwd, args, exitCode: Number(exitCode) }); + } + }).pipe(Effect.provide(BunServices.layer)); + +const initializeRepository = (projectRoot: string) => + Effect.gen(function* () { + yield* mkdir(projectRoot, { recursive: true }); + yield* runGit(projectRoot, ["init", "--initial-branch=main"]); + yield* runGit(projectRoot, ["config", "user.email", "stack-tests@supabase.local"]); + yield* runGit(projectRoot, ["config", "user.name", "Stack Tests"]); + yield* runGit(projectRoot, ["commit", "--allow-empty", "-m", "initial"]); + }); function buildLayer(opts: { cwd: string; @@ -66,6 +120,7 @@ function buildLayer(opts: { const discoveredCliConfigLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(discoveredProjectContextLayer), + Layer.provideMerge(BunServices.layer), ); const discoveredProjectHomeLayer = projectHomeLayer.pipe( Layer.provide(BunServices.layer), @@ -147,11 +202,9 @@ describe("link handler", () => { const initialConfig = 'project_id = "legacy-project"\n'; return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => initializeRepository(projectRoot)); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), initialConfig), - ); + yield* mkdir(join(projectRoot, "supabase"), { recursive: true }); + yield* initializeRepository(projectRoot); + yield* writeFile(join(projectRoot, "supabase", "config.toml"), initialConfig); const { layer, out, analytics } = buildLayer({ cwd: projectRoot, @@ -163,17 +216,11 @@ describe("link handler", () => { yield* link({ projectRef: Option.some(projectRef) }).pipe(Effect.provide(layer)); - const configContent = yield* Effect.tryPromise(() => - readFile(join(projectRoot, "supabase", "config.toml"), "utf8"), - ); + const configContent = yield* readFile(join(projectRoot, "supabase", "config.toml"), "utf8"); expect(configContent).toBe(initialConfig); - expect( - yield* Effect.tryPromise(() => readFile(join(projectRoot, ".gitignore"), "utf8")), - ).toContain(".supabase/"); + expect(yield* readFile(join(projectRoot, ".gitignore"), "utf8")).toContain(".supabase/"); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const cached = yield* linkState.load; expect(Option.isSome(cached)).toBe(true); if (Option.isSome(cached)) { @@ -222,9 +269,7 @@ describe("link handler", () => { organization_slug: "my-org", }, }); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("links successfully without requiring a local Supabase config", () => { @@ -234,7 +279,7 @@ describe("link handler", () => { const projectRef = "abcdefghijklmnopqrst"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => initializeRepository(projectRoot)); + yield* initializeRepository(projectRoot); const { layer } = buildLayer({ cwd: projectRoot, @@ -244,19 +289,13 @@ describe("link handler", () => { yield* link({ projectRef: Option.some(projectRef) }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const cached = yield* linkState.load; expect(Option.isSome(cached)).toBe(true); expect(Option.isSome(cached) && cached.value.project.ref).toBe(projectRef); - expect( - yield* Effect.tryPromise(() => readFile(join(projectRoot, ".gitignore"), "utf8")), - ).toContain(".supabase/"); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(yield* readFile(join(projectRoot, ".gitignore"), "utf8")).toContain(".supabase/"); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("active_branch.ref matches project.ref on a default fresh link (round-trip)", () => { @@ -266,7 +305,7 @@ describe("link handler", () => { const projectRef = "abcdefghijklmnopqrst"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => initializeRepository(projectRoot)); + yield* initializeRepository(projectRoot); const { layer } = buildLayer({ cwd: projectRoot, @@ -276,9 +315,7 @@ describe("link handler", () => { yield* link({ projectRef: Option.some(projectRef) }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const activeBranch = yield* linkState.getActiveBranch; expect(Option.isSome(activeBranch)).toBe(true); @@ -287,9 +324,7 @@ describe("link handler", () => { expect(activeBranch.value.name).toBe("main"); expect(activeBranch.value.is_default).toBe(true); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("selects an accessible project interactively when no project ref is provided", () => { @@ -300,10 +335,8 @@ describe("link handler", () => { const initialConfig = "# local project config\n"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), initialConfig), - ); + yield* mkdir(join(projectRoot, "supabase"), { recursive: true }); + yield* writeFile(join(projectRoot, "supabase", "config.toml"), initialConfig); const { layer, out } = buildLayer({ cwd: projectRoot, @@ -321,14 +354,10 @@ describe("link handler", () => { yield* link({ projectRef: Option.none() }).pipe(Effect.provide(layer)); - const configContent = yield* Effect.tryPromise(() => - readFile(join(projectRoot, "supabase", "config.toml"), "utf8"), - ); + const configContent = yield* readFile(join(projectRoot, "supabase", "config.toml"), "utf8"); expect(configContent).toBe(initialConfig); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); const cached = yield* linkState.load; expect(Option.isSome(cached)).toBe(true); if (Option.isSome(cached)) { @@ -352,9 +381,7 @@ describe("link handler", () => { }, }, ]); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("prompts before refreshing an existing interactive link", () => { @@ -364,7 +391,7 @@ describe("link handler", () => { const projectRef = "abcdefghijklmnopqrst"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => initializeRepository(projectRoot)); + yield* initializeRepository(projectRoot); const { layer, out } = buildLayer({ cwd: projectRoot, @@ -373,9 +400,7 @@ describe("link handler", () => { interactive: true, }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save({ project: { @@ -434,9 +459,7 @@ describe("link handler", () => { storage: "v1.39.2", }); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("allows choosing a different project when already linked interactively", () => { @@ -447,7 +470,7 @@ describe("link handler", () => { const newProjectRef = "qrstabcdefghijklmnop"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => initializeRepository(projectRoot)); + yield* initializeRepository(projectRoot); const { layer, out } = buildLayer({ cwd: projectRoot, @@ -464,9 +487,7 @@ describe("link handler", () => { promptSelectResponses: ["relink", newProjectRef], }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save({ project: { @@ -528,9 +549,7 @@ describe("link handler", () => { expect(cached.value.project.ref).toBe(newProjectRef); expect(cached.value.project.name).toBe("Linked Project"); } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("fails in non-interactive mode when no project ref is available", () => { @@ -539,8 +558,8 @@ describe("link handler", () => { const supabaseHome = join(tempDir, "supabase-home"); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + yield* mkdir(join(projectRoot, "supabase"), { recursive: true }); + yield* writeFile(join(projectRoot, "supabase", "config.toml"), ""); const { layer } = buildLayer({ cwd: projectRoot, @@ -558,9 +577,7 @@ describe("link handler", () => { expect(error.suggestion).toBe( "Pass --project-ref or link this checkout interactively first.", ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("makes cached-link refresh explicit in non-interactive mode", () => { @@ -570,7 +587,7 @@ describe("link handler", () => { const projectRef = "abcdefghijklmnopqrst"; return Effect.gen(function* () { - yield* Effect.tryPromise(() => initializeRepository(projectRoot)); + yield* initializeRepository(projectRoot); const { layer, out } = buildLayer({ cwd: projectRoot, @@ -578,9 +595,7 @@ describe("link handler", () => { remoteProjectRef: projectRef, }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); yield* linkState.save({ project: { @@ -608,9 +623,7 @@ describe("link handler", () => { message: `This local project is already linked to Linked Project (${projectRef}); refreshing linked project metadata.`, }), ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); it.live("fails with NoAccessibleProjectsError when interactive selection has no projects", () => { @@ -619,8 +632,8 @@ describe("link handler", () => { const supabaseHome = join(tempDir, "supabase-home"); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + yield* mkdir(join(projectRoot, "supabase"), { recursive: true }); + yield* writeFile(join(projectRoot, "supabase", "config.toml"), ""); const { layer } = buildLayer({ cwd: projectRoot, @@ -639,8 +652,6 @@ describe("link handler", () => { expect(error.suggestion).toBe( "Create a project in the dashboard or log in with a different account.", ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(cleanupTempDir(tempDir))); }); }); diff --git a/apps/cli/src/next/commands/login/login.e2e.test.ts b/apps/cli/src/next/commands/login/login.e2e.test.ts index abcb9713cc..de27f35dd5 100644 --- a/apps/cli/src/next/commands/login/login.e2e.test.ts +++ b/apps/cli/src/next/commands/login/login.e2e.test.ts @@ -4,16 +4,18 @@ import { runSupabase } from "../../../../tests/helpers/cli.ts"; const LOGIN_TIMEOUT_MS = 5_000; describe("supabase login", () => { - test("succeeds with a valid token", { timeout: LOGIN_TIMEOUT_MS }, async () => { + test("succeeds with a valid token", { timeout: LOGIN_TIMEOUT_MS }, () => { const token = "sbp_" + "a".repeat(40); - const { stdout, exitCode } = await runSupabase(["login", "--token", token]); - expect(exitCode).toBe(0); - expect(stdout).toContain("Logged in successfully"); + return runSupabase(["login", "--token", token]).then(({ stdout, exitCode }) => { + expect(exitCode).toBe(0); + expect(stdout).toContain("Logged in successfully"); + }); }); - test("fails with an invalid token", { timeout: LOGIN_TIMEOUT_MS }, async () => { - const { stdout, stderr, exitCode } = await runSupabase(["login", "--token", "bad-token"]); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("Invalid access token format"); + test("fails with an invalid token", { timeout: LOGIN_TIMEOUT_MS }, () => { + return runSupabase(["login", "--token", "bad-token"]).then(({ stdout, stderr, exitCode }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("Invalid access token format"); + }); }); }); diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index 07058958ee..3307adf6b3 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -220,15 +220,13 @@ const browserOAuthFlow = Effect.fnUntraced(function* (flags: LoginFlags) { // body, and a pending 4xx / no signal stays "run supabase login". const { statusCode, decode } = err.cause; const network = statusCode === undefined && decode !== true; - return yield* Effect.fail( - new LoginFailedError({ - detail: "Login failed after maximum retries", - suggestion: "Try running `supabase login` again", - statusCode, - network, - decode, - }), - ); + return yield* new LoginFailedError({ + detail: "Login failed after maximum retries", + suggestion: "Try running `supabase login` again", + statusCode, + network, + decode, + }); } return yield* verifyWithRetries(remainingRetries - 1); }), diff --git a/apps/cli/src/next/commands/login/login.integration.test.ts b/apps/cli/src/next/commands/login/login.integration.test.ts index 0aa7a3deb3..bdd8c273ae 100644 --- a/apps/cli/src/next/commands/login/login.integration.test.ts +++ b/apps/cli/src/next/commands/login/login.integration.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Clock, Effect, FileSystem, Exit, Layer, Option, Path, Schema } from "effect"; import type { OutputFormat } from "../../../shared/output/types.ts"; import type { LoginFlags } from "./login.command.ts"; import { login } from "./login.handler.ts"; -import type { TelemetryConfig } from "../../../shared/telemetry/types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "../../../shared/telemetry/types.ts"; import { ApiError } from "../../auth/errors.ts"; import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; @@ -35,16 +33,35 @@ const NO_FLAGS: LoginFlags = { noBrowser: false, }; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-login-test-")); +function writeTelemetryConfig( + fs: FileSystem.FileSystem, + path: Path.Path, + dir: string, + config: TelemetryConfig, +) { + return fs.writeFileString( + path.join(dir, "telemetry.json"), + Schema.encodeSync(Schema.fromJsonString(TelemetryConfigSchema))(config), + ); } -function writeTelemetryConfig(dir: string, config: TelemetryConfig) { - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); +function readTelemetryConfig(fs: FileSystem.FileSystem, path: Path.Path, dir: string) { + return fs + .readFileString(path.join(dir, "telemetry.json")) + .pipe(Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(TelemetryConfigSchema)))); } -function readTelemetryConfig(dir: string): TelemetryConfig { - return JSON.parse(readFileSync(path.join(dir, "telemetry.json"), "utf8")); +function withTempHome<A, E, R>( + body: (homeDir: string, fs: FileSystem.FileSystem, path: Path.Path) => Effect.Effect<A, E, R>, +) { + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-login-test-" }); + return yield* body(homeDir, fs, path); + }), + ); } // --------------------------------------------------------------------------- @@ -183,104 +200,100 @@ describe("login", () => { }); it.live("token-based login in an ephemeral runtime stamps without alias or file write", () => { - const homeDir = makeTempDir(); - const identity = makeTelemetryIdentity(undefined); - const { layer, analytics } = setupWithEnv({ SUPABASE_HOME: homeDir }, { isTTY: false }); - const runtime = TelemetryRuntime.of({ - configDir: homeDir, - tracesDir: path.join(homeDir, "traces"), - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity, - isFirstRun: false, - isTty: false, - isCi: true, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }); - return Effect.gen(function* () { - yield* login({ ...NO_FLAGS, token: Option.some(VALID_TOKEN) }).pipe( - Effect.provideService(TelemetryRuntime, runtime), - ); - expect(identity.current()).toBe("user-123"); - expect(analytics.aliased).toEqual([]); - expect(analytics.identified).toEqual([]); - expect(existsSync(path.join(homeDir, "telemetry.json"))).toBe(false); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); + return withTempHome((homeDir, fs, path) => { + const identity = makeTelemetryIdentity(undefined); + const { layer, analytics } = setupWithEnv({ SUPABASE_HOME: homeDir }, { isTTY: false }); + const runtime = TelemetryRuntime.of({ + configDir: homeDir, + tracesDir: path.join(homeDir, "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity, + isFirstRun: false, + isTty: false, + isCi: true, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }); + return Effect.gen(function* () { + yield* login({ ...NO_FLAGS, token: Option.some(VALID_TOKEN) }).pipe( + Effect.provideService(TelemetryRuntime, runtime), + ); + expect(identity.current()).toBe("user-123"); + expect(analytics.aliased).toEqual([]); + expect(analytics.identified).toEqual([]); + expect(yield* fs.exists(path.join(homeDir, "telemetry.json"))).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("token-based re-login as a different user persists without re-aliasing", () => { - const homeDir = makeTempDir(); - const identity = makeTelemetryIdentity("user-a"); - const { layer, analytics } = setupWithEnv({ SUPABASE_HOME: homeDir }, { isTTY: false }); - const runtime = TelemetryRuntime.of({ - configDir: homeDir, - tracesDir: path.join(homeDir, "traces"), - consent: "granted", - showDebug: false, - deviceId: "test-device-id", - sessionId: "test-session-id", - identity, - isFirstRun: false, - isTty: true, - isCi: false, - os: "linux", - arch: "x64", - cliVersion: "0.1.0", - }); - return Effect.gen(function* () { - yield* login({ ...NO_FLAGS, token: Option.some(VALID_TOKEN) }).pipe( - Effect.provideService(TelemetryRuntime, runtime), - ); - expect(identity.current()).toBe("user-123"); - expect(analytics.aliased).toEqual([]); - expect(readTelemetryConfig(homeDir).distinct_id).toBe("user-123"); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); + return withTempHome((homeDir, fs, path) => { + const identity = makeTelemetryIdentity("user-a"); + const { layer, analytics } = setupWithEnv({ SUPABASE_HOME: homeDir }, { isTTY: false }); + const runtime = TelemetryRuntime.of({ + configDir: homeDir, + tracesDir: path.join(homeDir, "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity, + isFirstRun: false, + isTty: true, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }); + return Effect.gen(function* () { + yield* login({ ...NO_FLAGS, token: Option.some(VALID_TOKEN) }).pipe( + Effect.provideService(TelemetryRuntime, runtime), + ); + expect(identity.current()).toBe("user-123"); + expect(analytics.aliased).toEqual([]); + expect((yield* readTelemetryConfig(fs, path, homeDir)).distinct_id).toBe("user-123"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("token-based login clears a stale distinct_id when profile lookup fails", () => { - const homeDir = makeTempDir(); - writeTelemetryConfig(homeDir, { - consent: "granted", - device_id: "device-123", - session_id: "session-123", - session_last_active: Date.now(), - distinct_id: "old-user-id", - }); - const creds = mockCredentials(); - const out = mockOutput(); - const api = mockApi({ profileError: new ApiError({ detail: "Unauthorized" }) }); - const analytics = mockAnalytics(); - const layer = Layer.mergeAll( - withEnv({ SUPABASE_HOME: homeDir }), - analytics.layer, - api.layer, - creds.layer, - mockCrypto(), - mockBrowser(), - mockStdin(false), - out.layer, - ); - - return Effect.gen(function* () { - yield* login({ ...NO_FLAGS, token: Option.some(VALID_TOKEN) }); - expect(creds.savedToken).toBe(VALID_TOKEN); - expect(readTelemetryConfig(homeDir).distinct_id).toBeUndefined(); - expect(analytics.identified).toEqual([]); - expect(analytics.aliased).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); + return withTempHome((homeDir, fs, path) => + Effect.gen(function* () { + yield* writeTelemetryConfig(fs, path, homeDir, { + consent: "granted", + device_id: "device-123", + session_id: "session-123", + session_last_active: yield* Clock.currentTimeMillis, + distinct_id: "old-user-id", + }); + const creds = mockCredentials(); + const out = mockOutput(); + const api = mockApi({ profileError: new ApiError({ detail: "Unauthorized" }) }); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + withEnv({ SUPABASE_HOME: homeDir }), + analytics.layer, + api.layer, + creds.layer, + mockCrypto(), + mockBrowser(), + mockStdin(false), + out.layer, + ); + + yield* login({ ...NO_FLAGS, token: Option.some(VALID_TOKEN) }).pipe( + Effect.provide(layer), + ); + expect(creds.savedToken).toBe(VALID_TOKEN); + expect((yield* readTelemetryConfig(fs, path, homeDir)).distinct_id).toBeUndefined(); + expect(analytics.identified).toEqual([]); + expect(analytics.aliased).toEqual([]); + }), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("returns NoTtyError when piped stdin is empty", () => { @@ -422,38 +435,36 @@ describe("login", () => { }); it.live("browser OAuth clears a stale distinct_id when profile lookup fails", () => { - const homeDir = makeTempDir(); - writeTelemetryConfig(homeDir, { - consent: "granted", - device_id: "device-123", - session_id: "session-123", - session_last_active: Date.now(), - distinct_id: "old-user-id", - }); - const creds = mockCredentials(); - const out = mockOutput(); - const api = mockApi({ profileError: new ApiError({ detail: "Unauthorized" }) }); - const analytics = mockAnalytics(); - const layer = Layer.mergeAll( - withEnv({ SUPABASE_HOME: homeDir }), - analytics.layer, - api.layer, - creds.layer, - mockCrypto(), - mockBrowser(), - mockStdin(true), - out.layer, - ); - - return Effect.gen(function* () { - yield* login(NO_FLAGS); - expect(readTelemetryConfig(homeDir).distinct_id).toBeUndefined(); - expect(analytics.aliased).toEqual([]); - expect(analytics.identified).toEqual([]); - }).pipe( - Effect.provide(layer), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); + return withTempHome((homeDir, fs, path) => + Effect.gen(function* () { + yield* writeTelemetryConfig(fs, path, homeDir, { + consent: "granted", + device_id: "device-123", + session_id: "session-123", + session_last_active: yield* Clock.currentTimeMillis, + distinct_id: "old-user-id", + }); + const creds = mockCredentials(); + const out = mockOutput(); + const api = mockApi({ profileError: new ApiError({ detail: "Unauthorized" }) }); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + withEnv({ SUPABASE_HOME: homeDir }), + analytics.layer, + api.layer, + creds.layer, + mockCrypto(), + mockBrowser(), + mockStdin(true), + out.layer, + ); + + yield* login(NO_FLAGS).pipe(Effect.provide(layer)); + expect((yield* readTelemetryConfig(fs, path, homeDir)).distinct_id).toBeUndefined(); + expect(analytics.aliased).toEqual([]); + expect(analytics.identified).toEqual([]); + }), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("browser OAuth stitches the authenticated user via /v1/profile", () => { diff --git a/apps/cli/src/next/commands/logout/logout.e2e.test.ts b/apps/cli/src/next/commands/logout/logout.e2e.test.ts index 5718c4f52a..9a7a6d94d9 100644 --- a/apps/cli/src/next/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/next/commands/logout/logout.e2e.test.ts @@ -2,20 +2,25 @@ import { describe, expect, test } from "vitest"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; describe("supabase logout", () => { - test("shows help text", async () => { - const { stdout, exitCode } = await runSupabase(["logout", "--help"]); - expect(exitCode).toBe(0); - expect(stdout).toContain("Log out of Supabase"); + test("shows help text", () => { + return runSupabase(["logout", "--help"]).then(({ stdout, exitCode }) => { + expect(exitCode).toBe(0); + expect(stdout).toContain("Log out of Supabase"); + }); }); - test("exits with error in non-interactive JSON mode without --yes", async () => { - const { stdout, stderr, exitCode } = await runSupabase(["logout", "--output-format", "json"]); - expect(exitCode).toBe(1); - expect(`${stdout}${stderr}`).toContain("prompt for confirmation"); + test("exits with error in non-interactive JSON mode without --yes", () => { + return runSupabase(["logout", "--output-format", "json"]).then( + ({ stdout, stderr, exitCode }) => { + expect(exitCode).toBe(1); + expect(`${stdout}${stderr}`).toContain("prompt for confirmation"); + }, + ); }); - test("succeeds with --yes in JSON mode when not logged in", async () => { - const { exitCode } = await runSupabase(["logout", "--yes", "--output-format", "json"]); - expect(exitCode).toBe(0); + test("succeeds with --yes in JSON mode when not logged in", () => { + return runSupabase(["logout", "--yes", "--output-format", "json"]).then(({ exitCode }) => { + expect(exitCode).toBe(0); + }); }); }); diff --git a/apps/cli/src/next/commands/logs/logs.handler.ts b/apps/cli/src/next/commands/logs/logs.handler.ts index 090d46a513..b7a847f059 100644 --- a/apps/cli/src/next/commands/logs/logs.handler.ts +++ b/apps/cli/src/next/commands/logs/logs.handler.ts @@ -1,5 +1,5 @@ import { connectLayer, Stack } from "@supabase/stack/effect"; -import { Context, Effect, Layer, Stream } from "effect"; +import { Context, DateTime, Effect, Layer, Stream } from "effect"; import { CliConfig } from "../../config/cli-config.service.ts"; import { ProjectHome } from "../../config/project-home.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -37,7 +37,7 @@ function emitLogEntry( if (output.format === "stream-json") { return output.event({ type: "log-entry", - timestamp: new Date(entry.timestamp).toISOString(), + timestamp: DateTime.formatIso(DateTime.makeUnsafe(entry.timestamp)), service: entry.service, stream: entry.stream, line: entry.line, diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index d47ab9481b..b6eed19ebe 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/prefer-schema-over-json -- this integration assertion decodes the exact user-visible JSON output boundary. import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit, Layer } from "effect"; diff --git a/apps/cli/src/next/commands/platform/platform-bodies.integration.test.ts b/apps/cli/src/next/commands/platform/platform-bodies.integration.test.ts index 6280785573..a9662c9fd2 100644 --- a/apps/cli/src/next/commands/platform/platform-bodies.integration.test.ts +++ b/apps/cli/src/next/commands/platform/platform-bodies.integration.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { Effect, Layer, Option } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { BunServices } from "@effect/platform-bun"; import { makeApiClient } from "@supabase/api/effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -23,6 +23,10 @@ function httpClientLayer( ); } +function testLayer(out: ReturnType<typeof mockOutput>) { + return Layer.mergeAll(BunServices.layer, out.layer, mockStdin(true), unusedPlatformApiLayer); +} + const unusedPlatformApiLayer = Layer.effect( PlatformApi, makeApiClient({ @@ -43,22 +47,22 @@ function findPlatformOperationDescriptor(operationId: string) { } describe("platform body handling", () => { - it("accepts JSON array bodies via --body", async () => { - const descriptor = findPlatformOperationDescriptor("v1BulkCreateSecrets"); - const out = mockOutput({ format: "json" }); - let capturedInput: unknown; + it.live("accepts JSON array bodies via --body", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1BulkCreateSecrets"); + const out = mockOutput({ format: "json" }); + let capturedInput: unknown; - const handler = runPlatformOperation({ - descriptor, - execute: (input) => - Effect.sync(() => { - capturedInput = input; - return { ok: true }; - }), - }); + const handler = runPlatformOperation({ + descriptor, + execute: (input) => + Effect.sync(() => { + capturedInput = input; + return { ok: true }; + }), + }); - await Effect.runPromise( - handler({ + yield* handler({ params: Option.some('{"ref":"abcdefghijklmnopqrst"}'), json: Option.none(), body: Option.some('[{"name":"MY_SECRET","value":"super-secret"}]'), @@ -68,139 +72,139 @@ describe("platform body handling", () => { schema: false, dryRun: false, yes: true, - }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); + }).pipe(Effect.provide(testLayer(out))); - expect(capturedInput).toEqual({ - ref: "abcdefghijklmnopqrst", - body: [{ name: "MY_SECRET", value: "super-secret" }], - }); - }); + expect(capturedInput).toEqual({ + ref: "abcdefghijklmnopqrst", + body: [{ name: "MY_SECRET", value: "super-secret" }], + }); + }), + ); - it("accepts binary request bodies from --body-file", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAFunction"); - const out = mockOutput({ format: "json" }); - let capturedInput: unknown; - const filePath = "/tmp/platform-function.eszip"; - await Bun.write(filePath, "eszip-bundle"); + it.live("accepts binary request bodies from --body-file", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAFunction"); + const out = mockOutput({ format: "json" }); + let capturedInput: unknown; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "platform-bodies-" }); + yield* Effect.gen(function* () { + const filePath = path.join(tempDir, "platform-function.eszip"); + yield* fs.writeFileString(filePath, "eszip-bundle"); - const handler = runPlatformOperation({ - descriptor, - execute: (input) => - Effect.sync(() => { - capturedInput = input; - return { ok: true }; - }), - }); + const handler = runPlatformOperation({ + descriptor, + execute: (input) => + Effect.sync(() => { + capturedInput = input; + return { ok: true }; + }), + }); - await Effect.runPromise( - handler({ - params: Option.some('{"ref":"abcdefghijklmnopqrst","slug":"my-function"}'), - json: Option.none(), - body: Option.none(), - bodyFile: Option.some(filePath), - upload: [], - fields: Option.none(), - schema: false, - dryRun: false, - yes: true, - }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); + yield* handler({ + params: Option.some('{"ref":"abcdefghijklmnopqrst","slug":"my-function"}'), + json: Option.none(), + body: Option.none(), + bodyFile: Option.some(filePath), + upload: [], + fields: Option.none(), + schema: false, + dryRun: false, + yes: true, + }).pipe(Effect.provide(testLayer(out))); - expect(capturedInput).toEqual( - expect.objectContaining({ - ref: "abcdefghijklmnopqrst", - slug: "my-function", - }), - ); - expect(textDecoder.decode((capturedInput as { body: Uint8Array }).body)).toBe("eszip-bundle"); - }); + expect(capturedInput).toEqual( + expect.objectContaining({ + ref: "abcdefghijklmnopqrst", + slug: "my-function", + }), + ); + expect(textDecoder.decode((capturedInput as { body: Uint8Array }).body)).toBe( + "eszip-bundle", + ); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("accepts multipart request bodies via --json and --upload", async () => { - const descriptor = findPlatformOperationDescriptor("v1DeployAFunction"); - const out = mockOutput({ format: "json" }); - let capturedInput: unknown; - const firstFilePath = "/tmp/platform-function-deploy-1.eszip"; - const secondFilePath = "/tmp/platform-function-deploy-2.json"; - await Bun.write(firstFilePath, "bundle.eszip"); - await Bun.write(secondFilePath, "deno.json"); + it.live("accepts multipart request bodies via --json and --upload", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1DeployAFunction"); + const out = mockOutput({ format: "json" }); + let capturedInput: unknown; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "platform-bodies-" }); + yield* Effect.gen(function* () { + const firstFilePath = path.join(tempDir, "platform-function-deploy-1.eszip"); + const secondFilePath = path.join(tempDir, "platform-function-deploy-2.json"); + yield* fs.writeFileString(firstFilePath, "bundle.eszip"); + yield* fs.writeFileString(secondFilePath, "deno.json"); - const handler = runPlatformOperation({ - descriptor, - execute: (input) => - Effect.sync(() => { - capturedInput = input; - return { ok: true }; - }), - }); + const handler = runPlatformOperation({ + descriptor, + execute: (input) => + Effect.sync(() => { + capturedInput = input; + return { ok: true }; + }), + }); - await Effect.runPromise( - handler({ - params: Option.some('{"ref":"abcdefghijklmnopqrst","slug":"my-function"}'), - json: Option.some('{"metadata":{"entrypoint_path":"index.ts","verify_jwt":true}}'), - body: Option.none(), - bodyFile: Option.none(), - upload: [`file=${firstFilePath}`, `file=${secondFilePath}`], - fields: Option.none(), - schema: false, - dryRun: false, - yes: true, - }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); + yield* handler({ + params: Option.some('{"ref":"abcdefghijklmnopqrst","slug":"my-function"}'), + json: Option.some('{"metadata":{"entrypoint_path":"index.ts","verify_jwt":true}}'), + body: Option.none(), + bodyFile: Option.none(), + upload: [`file=${firstFilePath}`, `file=${secondFilePath}`], + fields: Option.none(), + schema: false, + dryRun: false, + yes: true, + }).pipe(Effect.provide(testLayer(out))); - expect(capturedInput).toEqual( - expect.objectContaining({ - ref: "abcdefghijklmnopqrst", - slug: "my-function", - body: { - metadata: { - entrypoint_path: "index.ts", - verify_jwt: true, - }, - file: expect.any(Array), - }, - }), - ); - const files = (capturedInput as { body: { file: Uint8Array[] } }).body.file; - expect(files.map((file) => textDecoder.decode(file))).toEqual(["bundle.eszip", "deno.json"]); - }); + expect(capturedInput).toEqual( + expect.objectContaining({ + ref: "abcdefghijklmnopqrst", + slug: "my-function", + body: { + metadata: { + entrypoint_path: "index.ts", + verify_jwt: true, + }, + file: expect.any(Array), + }, + }), + ); + const files = (capturedInput as { body: { file: Uint8Array[] } }).body.file; + expect(files.map((file) => textDecoder.decode(file))).toEqual([ + "bundle.eszip", + "deno.json", + ]); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("accepts urlencoded request bodies via --json", async () => { - const descriptor = findPlatformOperationDescriptor("v1ExchangeOauthToken"); - const out = mockOutput({ format: "json" }); - let capturedInput: unknown; + it.live("accepts urlencoded request bodies via --json", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1ExchangeOauthToken"); + const out = mockOutput({ format: "json" }); + let capturedInput: unknown; - const handler = runPlatformOperation({ - descriptor, - execute: (input) => - Effect.sync(() => { - capturedInput = input; - return { - access_token: "token", - refresh_token: "refresh", - expires_in: 3600, - token_type: "Bearer", - }; - }), - }); + const handler = runPlatformOperation({ + descriptor, + execute: (input) => + Effect.sync(() => { + capturedInput = input; + return { + access_token: "token", + refresh_token: "refresh", + expires_in: 3600, + token_type: "Bearer", + }; + }), + }); - await Effect.runPromise( - handler({ + yield* handler({ params: Option.none(), json: Option.some('{"grant_type":"refresh_token","refresh_token":"refresh-token"}'), body: Option.none(), @@ -210,30 +214,25 @@ describe("platform body handling", () => { schema: false, dryRun: false, yes: true, - }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); + }).pipe(Effect.provide(testLayer(out))); - expect(capturedInput).toEqual({ - body: { - grant_type: "refresh_token", - refresh_token: "refresh-token", - }, - }); - }); + expect(capturedInput).toEqual({ + body: { + grant_type: "refresh_token", + refresh_token: "refresh-token", + }, + }); + }), + ); - it("renders urlencoded dry-run previews with the expected body kind", async () => { - const descriptor = findPlatformOperationDescriptor("v1ExchangeOauthToken"); - const out = mockOutput({ format: "json" }); + it.live("renders urlencoded dry-run previews with the expected body kind", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1ExchangeOauthToken"); + const out = mockOutput({ format: "json" }); - const handler = runPlatformOperation({ descriptor }); + const handler = runPlatformOperation({ descriptor }); - await Effect.runPromise( - handler({ + yield* handler({ params: Option.none(), json: Option.some('{"grant_type":"refresh_token","refresh_token":"refresh-token"}'), body: Option.none(), @@ -243,26 +242,21 @@ describe("platform body handling", () => { schema: false, dryRun: true, yes: true, - }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); + }).pipe(Effect.provide(testLayer(out))); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "", - data: expect.objectContaining({ - dryRun: true, - bodyKind: "urlencoded", - body: expect.objectContaining({ - grant_type: "refresh_token", + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "", + data: expect.objectContaining({ + dryRun: true, + bodyKind: "urlencoded", + body: expect.objectContaining({ + grant_type: "refresh_token", + }), }), }), - }), - ); - }); + ); + }), + ); }); diff --git a/apps/cli/src/next/commands/platform/platform-examples.unit.test.ts b/apps/cli/src/next/commands/platform/platform-examples.unit.test.ts index e22c36db1c..3b120beb21 100644 --- a/apps/cli/src/next/commands/platform/platform-examples.unit.test.ts +++ b/apps/cli/src/next/commands/platform/platform-examples.unit.test.ts @@ -1,8 +1,6 @@ -import { readFileSync } from "node:fs"; -import { dirname } from "node:path"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; import { getHelpDoc } from "../../docs/command-docs.ts"; import { apiRequestCommand } from "./request.command.ts"; @@ -145,14 +143,17 @@ describe("platform example generation", () => { } }); - it("keeps operation-specific logic isolated to the override map", () => { - const sourcePath = path.resolve( - dirname(fileURLToPath(import.meta.url)), - "platform-examples.ts", - ); - const source = readFileSync(sourcePath, "utf8"); - - expect(source).not.toMatch(/case\s+"v1[A-Za-z0-9]+"/); - expect(source).toMatch(/bodyExampleOverrides/); - }); + it.effect("keeps operation-specific logic isolated to the override map", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const sourcePath = yield* path.fromFileUrl( + new URL("./platform-examples.ts", import.meta.url), + ); + const source = yield* fs.readFileString(sourcePath); + + expect(source).not.toMatch(/case\s+"v1[A-Za-z0-9]+"/); + expect(source).toMatch(/bodyExampleOverrides/); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/next/commands/platform/platform-handler.ts b/apps/cli/src/next/commands/platform/platform-handler.ts index 60ab36d65c..144c3498c6 100644 --- a/apps/cli/src/next/commands/platform/platform-handler.ts +++ b/apps/cli/src/next/commands/platform/platform-handler.ts @@ -1,4 +1,4 @@ -import { Effect, Exit, Option } from "effect"; +import { DateTime, Effect, Exit, Option } from "effect"; import { Output } from "../../../shared/output/output.service.ts"; import { parsePlatformFieldsSelection, @@ -74,7 +74,7 @@ export function runPlatformOperation< yield* output.event({ type: "result", data: payload, - timestamp: new Date().toISOString(), + timestamp: DateTime.formatIso(yield* DateTime.now), }); return; } diff --git a/apps/cli/src/next/commands/platform/platform-input.ts b/apps/cli/src/next/commands/platform/platform-input.ts index db3a7bac10..d5666cd500 100644 --- a/apps/cli/src/next/commands/platform/platform-input.ts +++ b/apps/cli/src/next/commands/platform/platform-input.ts @@ -12,9 +12,11 @@ import type { } from "./platform-types.ts"; type JsonRecord = Record<string, unknown>; -type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +type JsonValue = Schema.Json; const textEncoder = new TextEncoder(); type MultipartUploadKind = "single" | "array"; +const decodeJsonText = (raw: string): Schema.Json => + Schema.decodeSync(Schema.fromJsonString(Schema.Json))(raw); const isRecord = (value: unknown): value is JsonRecord => typeof value === "object" && value !== null && !Array.isArray(value); @@ -37,11 +39,8 @@ const parseJsonRecord = ( raw: string, kind: "json" | "params", ): Effect.Effect<JsonRecord, PlatformInputError> => - Effect.try({ - try: () => JSON.parse(raw), - catch: (cause) => - invalidJsonInput(kind, cause instanceof Error ? cause.message : String(cause)), - }).pipe( + Schema.decodeEffect(Schema.fromJsonString(Schema.Json))(raw).pipe( + Effect.mapError((cause) => invalidJsonInput(kind, String(cause))), Effect.flatMap((value) => isRecord(value) ? Effect.succeed(value) @@ -61,11 +60,9 @@ const readJsonSource = ( if (raw === "-") { const piped = yield* stdin.readPipedText; if (Option.isNone(piped)) { - return yield* Effect.fail( - invalidJsonInput( - kind, - `No piped stdin content was available for ${formatSourceLabel(kind)}.`, - ), + return yield* invalidJsonInput( + kind, + `No piped stdin content was available for ${formatSourceLabel(kind)}.`, ); } return yield* parseJsonRecord(piped.value, kind); @@ -73,11 +70,9 @@ const readJsonSource = ( return yield* parseJsonRecord(raw, kind); }).pipe( - Effect.catch((cause) => - Effect.fail( - toPlatformInputError(cause, () => - invalidJsonInput(kind, cause instanceof Error ? cause.message : String(cause)), - ), + Effect.mapError((cause) => + toPlatformInputError(cause, () => + invalidJsonInput(kind, cause instanceof Error ? cause.message : String(cause)), ), ), ); @@ -198,12 +193,10 @@ export function mergePlatformInput(options: { return Effect.gen(function* () { if (Option.isSome(options.jsonValues)) { if (!expectsStructuredJsonBody) { - return yield* Effect.fail( - new PlatformInputError({ - message: `This command does not accept ${formatSourceLabel("json")}.`, - suggestion: unsupportedJsonSuggestion(options.descriptor), - }), - ); + return yield* new PlatformInputError({ + message: `This command does not accept ${formatSourceLabel("json")}.`, + suggestion: unsupportedJsonSuggestion(options.descriptor), + }); } yield* validateInputKeys( options.descriptor, @@ -270,12 +263,10 @@ const requireInteractivePrompts = Effect.gen(function* () { const stdin = yield* Stdin; if (output.format !== "text" || !stdin.isTTY) { - return yield* Effect.fail( - new NonInteractiveError({ - detail: "Cannot prompt for missing platform request fields in non-interactive mode.", - suggestion: "Provide all required values with --json or --params.", - }), - ); + return yield* new NonInteractiveError({ + detail: "Cannot prompt for missing platform request fields in non-interactive mode.", + suggestion: "Provide all required values with --json or --params.", + }); } return output; @@ -314,13 +305,13 @@ const promptForField = ( return isNodeRequired(field) ? `${label} is required` : undefined; } try { - JSON.parse(value); + decodeJsonText(value); } catch (cause) { return cause instanceof Error ? cause.message : "Invalid JSON"; } }, }); - return JSON.parse(raw); + return decodeJsonText(raw); } case "integer": case "number": { @@ -384,20 +375,32 @@ export const decodePlatformInput = <S extends Schema.ConstraintDecoder<unknown, schema: S, input: JsonRecord, ): Effect.Effect<S["Type"], PlatformInputError> => - Effect.try({ - try: () => Schema.decodeUnknownSync(schema)(input), - catch: (cause) => - new PlatformInputError({ - message: "The request payload does not match the operation schema.", - detail: cause instanceof Error ? cause.message : String(cause), - suggestion: `Run \`${formatPlatformApiSchemaCommand(descriptor)}\` to inspect the documented request and response shape.`, - }), - }); + Schema.decodeEffect(schema)(input).pipe( + Effect.mapError( + (cause) => + new PlatformInputError({ + message: "The request payload does not match the operation schema.", + detail: String(cause), + suggestion: `Run \`${formatPlatformApiSchemaCommand(descriptor)}\` to inspect the documented request and response shape.`, + }), + ), + ); function interpolatePath(pathTemplate: string, input: JsonRecord): string { return pathTemplate.replaceAll(/\{([^}]+)\}/g, (_match, key: string) => { const value = input[key]; - return value === undefined ? `{${key}}` : encodeURIComponent(String(value)); + if (value === undefined) return `{${key}}`; + if (Array.isArray(value)) { + return encodeURIComponent(value.map((entry) => String(entry)).join(",")); + } + if (typeof value === "object" && value !== null) { + return encodeURIComponent(Object.keys(value).join(",")); + } + if (typeof value === "string") return encodeURIComponent(value); + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return encodeURIComponent(value.toString()); + } + return encodeURIComponent(value === null ? "null" : "undefined"); }); } @@ -537,14 +540,11 @@ function invalidBodyInput(detail: string, suggestion: string) { } function parseJsonValue(raw: string): Effect.Effect<JsonValue, PlatformInputError> { - return Effect.try({ - try: () => JSON.parse(raw) as JsonValue, - catch: (cause) => - invalidBodyInput( - cause instanceof Error ? cause.message : String(cause), - "Pass inline JSON or - for stdin to --body.", - ), - }); + return Schema.decodeEffect(Schema.fromJsonString(Schema.Json))(raw).pipe( + Effect.mapError((cause) => + invalidBodyInput(String(cause), "Pass inline JSON or - for stdin to --body."), + ), + ); } function readBodyText(raw: string): Effect.Effect<string, PlatformInputError, Stdin> { @@ -554,11 +554,9 @@ function readBodyText(raw: string): Effect.Effect<string, PlatformInputError, St if (raw === "-") { const piped = yield* stdin.readPipedText; if (Option.isNone(piped)) { - return yield* Effect.fail( - invalidBodyInput( - "No piped stdin content was available for --body.", - "Provide inline content or piped stdin to --body.", - ), + return yield* invalidBodyInput( + "No piped stdin content was available for --body.", + "Provide inline content or piped stdin to --body.", ); } return piped.value; @@ -566,13 +564,11 @@ function readBodyText(raw: string): Effect.Effect<string, PlatformInputError, St return raw; }).pipe( - Effect.catch((cause) => - Effect.fail( - toPlatformInputError(cause, () => - invalidBodyInput( - cause instanceof Error ? cause.message : String(cause), - "Pass inline content or - for stdin to --body.", - ), + Effect.mapError((cause) => + toPlatformInputError(cause, () => + invalidBodyInput( + cause instanceof Error ? cause.message : String(cause), + "Pass inline content or - for stdin to --body.", ), ), ), @@ -586,19 +582,18 @@ function readBodyFileText( const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(filePath); if (!exists) { - return yield* Effect.fail( - invalidBodyInput(`File not found: ${filePath}`, "Check the path passed to --body-file."), + return yield* invalidBodyInput( + `File not found: ${filePath}`, + "Check the path passed to --body-file.", ); } return yield* fs.readFileString(filePath); }).pipe( - Effect.catch((cause) => - Effect.fail( - toPlatformInputError(cause, () => - invalidBodyInput( - cause instanceof Error ? cause.message : String(cause), - "Pass a readable file path to --body-file.", - ), + Effect.mapError((cause) => + toPlatformInputError(cause, () => + invalidBodyInput( + cause instanceof Error ? cause.message : String(cause), + "Pass a readable file path to --body-file.", ), ), ), @@ -612,19 +607,18 @@ function readBodyFileBytes( const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(filePath); if (!exists) { - return yield* Effect.fail( - invalidBodyInput(`File not found: ${filePath}`, "Check the path passed to --body-file."), + return yield* invalidBodyInput( + `File not found: ${filePath}`, + "Check the path passed to --body-file.", ); } return yield* fs.readFile(filePath); }).pipe( - Effect.catch((cause) => - Effect.fail( - toPlatformInputError(cause, () => - invalidBodyInput( - cause instanceof Error ? cause.message : String(cause), - "Pass a readable file path to --body-file.", - ), + Effect.mapError((cause) => + toPlatformInputError(cause, () => + invalidBodyInput( + cause instanceof Error ? cause.message : String(cause), + "Pass a readable file path to --body-file.", ), ), ), @@ -638,11 +632,9 @@ function parseBinaryBody(raw: string): Effect.Effect<Uint8Array, PlatformInputEr if (raw === "-") { const piped = yield* stdin.readPipedBytes; if (Option.isNone(piped)) { - return yield* Effect.fail( - invalidBodyInput( - "No piped stdin content was available for --body.", - "This request expects raw bytes. Provide `--body-file <path>` or pipe bytes to `--body -`.", - ), + return yield* invalidBodyInput( + "No piped stdin content was available for --body.", + "This request expects raw bytes. Provide `--body-file <path>` or pipe bytes to `--body -`.", ); } return piped.value; @@ -650,13 +642,11 @@ function parseBinaryBody(raw: string): Effect.Effect<Uint8Array, PlatformInputEr return textEncoder.encode(raw); }).pipe( - Effect.catch((cause) => - Effect.fail( - toPlatformInputError(cause, () => - invalidBodyInput( - cause instanceof Error ? cause.message : String(cause), - "This request expects raw bytes. Use `--body-file <path>`, `--body -`, or inline text if you want UTF-8 bytes.", - ), + Effect.mapError((cause) => + toPlatformInputError(cause, () => + invalidBodyInput( + cause instanceof Error ? cause.message : String(cause), + "This request expects raw bytes. Use `--body-file <path>`, `--body -`, or inline text if you want UTF-8 bytes.", ), ), ), @@ -688,11 +678,9 @@ export const parsePlatformBodySource = ( ): Effect.Effect<Option.Option<unknown>, PlatformInputError, FileSystem.FileSystem | Stdin> => Effect.gen(function* () { if (Option.isSome(raw.body) && Option.isSome(raw.bodyFile)) { - return yield* Effect.fail( - invalidBodyInput( - "Cannot use --body and --body-file together.", - "Choose one raw body source and retry.", - ), + return yield* invalidBodyInput( + "Cannot use --body and --body-file together.", + "Choose one raw body source and retry.", ); } @@ -701,38 +689,30 @@ export const parsePlatformBodySource = ( } if (descriptor.kind === "none") { - return yield* Effect.fail( - invalidBodyInput( - "This command does not accept raw request body input.", - "Remove --body and --body-file and retry.", - ), + return yield* invalidBodyInput( + "This command does not accept raw request body input.", + "Remove --body and --body-file and retry.", ); } if (descriptor.kind === "json" && descriptor.schema?.kind === "object") { - return yield* Effect.fail( - invalidBodyInput( - "This command expects an object JSON body.", - "Use --json for object-shaped JSON request bodies.", - ), + return yield* invalidBodyInput( + "This command expects an object JSON body.", + "Use --json for object-shaped JSON request bodies.", ); } if (descriptor.kind === "multipart") { - return yield* Effect.fail( - invalidBodyInput( - "This command expects multipart input split across --json and --upload.", - "Use --json for structured fields and --upload field=path for binary fields.", - ), + return yield* invalidBodyInput( + "This command expects multipart input split across --json and --upload.", + "Use --json for structured fields and --upload field=path for binary fields.", ); } if (descriptor.kind === "urlencoded") { - return yield* Effect.fail( - invalidBodyInput( - "This command expects structured form fields.", - "Use --json for object-shaped request bodies. The CLI serializes them as urlencoded form data.", - ), + return yield* invalidBodyInput( + "This command expects structured form fields.", + "Use --json for object-shaped request bodies. The CLI serializes them as urlencoded form data.", ); } @@ -741,8 +721,9 @@ export const parsePlatformBodySource = ( return Option.some(yield* readBodyFileBytes(raw.bodyFile.value)); } if (Option.isNone(raw.body)) { - return yield* Effect.fail( - invalidBodyInput("Missing raw request body input.", "Provide --body or --body-file."), + return yield* invalidBodyInput( + "Missing raw request body input.", + "Provide --body or --body-file.", ); } return Option.some(yield* parseBinaryBody(raw.body.value)); @@ -752,8 +733,9 @@ export const parsePlatformBodySource = ( return Option.some(yield* parseNonObjectJsonBodyFile(raw.bodyFile.value)); } if (Option.isNone(raw.body)) { - return yield* Effect.fail( - invalidBodyInput("Missing request body input.", "Provide --body or --body-file."), + return yield* invalidBodyInput( + "Missing request body input.", + "Provide --body or --body-file.", ); } @@ -796,11 +778,9 @@ function readUploadBytes( const stdin = yield* Stdin; const piped = yield* stdin.readPipedBytes; if (Option.isNone(piped)) { - return yield* Effect.fail( - invalidUploadInput( - `No piped stdin content was available for multipart field "${field}".`, - `Pipe bytes to stdin or pass a file path to --upload ${field}=...`, - ), + return yield* invalidUploadInput( + `No piped stdin content was available for multipart field "${field}".`, + `Pipe bytes to stdin or pass a file path to --upload ${field}=...`, ); } return piped.value; @@ -811,22 +791,18 @@ function readUploadBytes( const fs = yield* FileSystem.FileSystem; const exists = yield* fs.exists(source); if (!exists) { - return yield* Effect.fail( - invalidUploadInput( - `File not found for multipart field "${field}": ${source}`, - `Check the path passed to --upload ${field}=...`, - ), + return yield* invalidUploadInput( + `File not found for multipart field "${field}": ${source}`, + `Check the path passed to --upload ${field}=...`, ); } return yield* fs.readFile(source); }).pipe( - Effect.catch((cause) => - Effect.fail( - toPlatformInputError(cause, () => - invalidUploadInput( - cause instanceof Error ? cause.message : String(cause), - `Check the path passed to --upload ${field}=...`, - ), + Effect.mapError((cause) => + toPlatformInputError(cause, () => + invalidUploadInput( + cause instanceof Error ? cause.message : String(cause), + `Check the path passed to --upload ${field}=...`, ), ), ), @@ -855,11 +831,9 @@ export const parsePlatformUploadSources = ( } if (descriptor.kind !== "multipart") { - return yield* Effect.fail( - invalidUploadInput( - "This command does not accept --upload.", - "Remove --upload and retry, or use --body-file for raw binary request bodies.", - ), + return yield* invalidUploadInput( + "This command does not accept --upload.", + "Remove --upload and retry, or use --body-file for raw binary request bodies.", ); } @@ -880,20 +854,16 @@ export const parsePlatformUploadSources = ( const kind = multipartUploadKind(property); if (property === undefined) { - return yield* Effect.fail( - invalidUploadInput( - `Unknown multipart upload field: ${field}`, - "Run `supabase api request <route> --schema` to inspect the multipart body shape.", - ), + return yield* invalidUploadInput( + `Unknown multipart upload field: ${field}`, + "Run `supabase api request <route> --schema` to inspect the multipart body shape.", ); } if (kind === undefined) { - return yield* Effect.fail( - invalidUploadInput( - `${field} is not a binary multipart field.`, - "Use --json for structured multipart fields and --upload only for binary fields.", - ), + return yield* invalidUploadInput( + `${field} is not a binary multipart field.`, + "Use --json for structured multipart fields and --upload only for binary fields.", ); } @@ -905,11 +875,9 @@ export const parsePlatformUploadSources = ( } if (uploads[field] !== undefined) { - return yield* Effect.fail( - invalidUploadInput( - `Multipart field "${field}" only accepts a single upload.`, - `Pass ${field}=... once, or use a repeated array-valued binary field if the schema supports it.`, - ), + return yield* invalidUploadInput( + `Multipart field "${field}" only accepts a single upload.`, + `Pass ${field}=... once, or use a repeated array-valued binary field if the schema supports it.`, ); } uploads[field] = value; diff --git a/apps/cli/src/next/commands/platform/platform-input.unit.test.ts b/apps/cli/src/next/commands/platform/platform-input.unit.test.ts index d1fced5640..037945b660 100644 --- a/apps/cli/src/next/commands/platform/platform-input.unit.test.ts +++ b/apps/cli/src/next/commands/platform/platform-input.unit.test.ts @@ -7,6 +7,7 @@ import { NonInteractiveError } from "../../../shared/output/errors.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { platformOperationDescriptors } from "./platform-descriptors.ts"; import { + buildPlatformRequestPreview, decodePlatformInput, mergePlatformInput, parsePlatformBodySource, @@ -64,6 +65,14 @@ describe("platform input", () => { }), ); + it("interpolates array path values instead of their numeric keys", () => { + const preview = buildPlatformRequestPreview(deleteBranchDescriptor, { + branch_id_or_ref: ["project-a", "project-b"], + }); + + expect(preview.path).toBe("/v1/branches/project-a%2Cproject-b"); + }); + it.effect("fails when json contains a non-body field", () => Effect.gen(function* () { const exit = yield* mergePlatformInput({ @@ -208,7 +217,7 @@ describe("platform input", () => { if (Option.isSome(body)) { expect(textDecoder.decode(body.value as Uint8Array)).toBe("eszip-bundle"); } - }).pipe(Effect.provide(BunServices.layer), Effect.provide(mockStdin(true))), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockStdin(true)))), ); it.effect("parses multipart binary upload flags into grouped arrays", () => @@ -236,7 +245,7 @@ describe("platform input", () => { "deno.json", ]); } - }).pipe(Effect.provide(BunServices.layer), Effect.provide(mockStdin(true))), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockStdin(true)))), ); it.effect("rejects unknown multipart upload fields", () => @@ -254,7 +263,7 @@ describe("platform input", () => { detail: "Unknown multipart upload field: missing", }), ); - }).pipe(Effect.provide(BunServices.layer), Effect.provide(mockStdin(true))), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockStdin(true)))), ); it.effect("rejects uploads targeting structured multipart fields", () => @@ -272,7 +281,7 @@ describe("platform input", () => { detail: "metadata is not a binary multipart field.", }), ); - }).pipe(Effect.provide(BunServices.layer), Effect.provide(mockStdin(true))), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockStdin(true)))), ); it.effect("rejects multiple stdin consumers across flags and uploads", () => @@ -314,7 +323,7 @@ describe("platform input", () => { suggestion: "Check the path passed to --body-file.", }), ); - }).pipe(Effect.provide(BunServices.layer), Effect.provide(mockStdin(true))), + }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, mockStdin(true)))), ); it.effect("uses the exact command in schema mismatch suggestions", () => @@ -347,7 +356,7 @@ describe("platform input", () => { name: "123456", organization_slug: "123456", }); - }).pipe(Effect.provide(out.layer), Effect.provide(mockStdin(true))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, mockStdin(true)))); }); it.live("prompts string-only union params as plain text", () => { @@ -407,7 +416,7 @@ describe("platform input", () => { branch_id_or_ref: "abcdefghijklmnopqrst", }); expect(prompts).toEqual(["Branch Id Or Ref"]); - }).pipe(Effect.provide(out), Effect.provide(mockStdin(true))); + }).pipe(Effect.provide(Layer.mergeAll(out, mockStdin(true)))); }); it.live("refuses to prompt in json mode", () => { @@ -417,6 +426,6 @@ describe("platform input", () => { Effect.exit, ); expect(getFailError(exit)).toBeInstanceOf(NonInteractiveError); - }).pipe(Effect.provide(out.layer), Effect.provide(mockStdin(true))); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, mockStdin(true)))); }); }); diff --git a/apps/cli/src/next/commands/platform/platform-output.ts b/apps/cli/src/next/commands/platform/platform-output.ts index ecb4deada5..cd40195a9c 100644 --- a/apps/cli/src/next/commands/platform/platform-output.ts +++ b/apps/cli/src/next/commands/platform/platform-output.ts @@ -1,9 +1,11 @@ -import { Effect, Stream } from "effect"; +import { Effect, Schema, Stream } from "effect"; import * as Stdio from "effect/Stdio"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + export function writePlatformJsonStdout(value: unknown) { return Effect.gen(function* () { const stdio = yield* Stdio.Stdio; - yield* Stream.make(JSON.stringify(value) + "\n").pipe(Stream.run(stdio.stdout()), Effect.orDie); + yield* Stream.make(encodeJson(value) + "\n").pipe(Stream.run(stdio.stdout()), Effect.orDie); }); } diff --git a/apps/cli/src/next/commands/platform/projects-create.integration.test.ts b/apps/cli/src/next/commands/platform/projects-create.integration.test.ts index d8f0feb5fe..995e785812 100644 --- a/apps/cli/src/next/commands/platform/projects-create.integration.test.ts +++ b/apps/cli/src/next/commands/platform/projects-create.integration.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { Effect, Exit, Layer, Option, Sink, Stream } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option, Schema, Sink, Stream } from "effect"; import { BunServices } from "@effect/platform-bun"; import { makeApiClient } from "@supabase/api/effect"; import * as Stdio from "effect/Stdio"; @@ -32,6 +32,9 @@ const unusedPlatformApiLayer = Layer.effect( }), ).pipe(Layer.provide(httpClientLayer(() => Effect.die("unused test client")))); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); +const decodeJson = Schema.decodeSync(Schema.fromJsonString(Schema.Json)); + function findPlatformOperationDescriptor(operationId: string) { const descriptor = platformOperationDescriptors.find( (candidate) => candidate.operationId === operationId, @@ -68,17 +71,17 @@ function mockStdio() { } describe("projects create platform handler", () => { - it("supports inline --json with dry-run output", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "json" }); + it.live("supports inline --json with dry-run output", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "json" }); - const handler = runPlatformOperation({ descriptor }); + const handler = runPlatformOperation({ descriptor }); - await Effect.runPromise( - handler({ + yield* handler({ params: Option.none(), json: Option.some( - JSON.stringify({ + encodeJson({ name: "from-inline", db_pass: "super-secret", organization_slug: "my-org", @@ -92,36 +95,35 @@ describe("projects create platform handler", () => { dryRun: true, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "", - data: expect.objectContaining({ - dryRun: true, - json: expect.objectContaining({ - name: "from-inline", - db_pass: "<redacted>", + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(true), unusedPlatformApiLayer, BunServices.layer), + ), + ); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "", + data: expect.objectContaining({ + dryRun: true, + json: expect.objectContaining({ + name: "from-inline", + db_pass: "<redacted>", + }), }), }), - }), - ); - }); + ); + }), + ); - it("supports stdin-backed --json with dry-run output", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "json" }); + it.live("supports stdin-backed --json with dry-run output", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "json" }); - const handler = runPlatformOperation({ descriptor }); + const handler = runPlatformOperation({ descriptor }); - await Effect.runPromise( - handler({ + yield* handler({ params: Option.none(), json: Option.some("-"), body: Option.none(), @@ -132,61 +134,67 @@ describe("projects create platform handler", () => { dryRun: true, yes: true, }).pipe( - Effect.provide(out.layer), Effect.provide( - mockStdin( - true, - '{"name":"from-stdin","db_pass":"stdin-secret","organization_slug":"my-org"}', + Layer.mergeAll( + out.layer, + mockStdin( + true, + encodeJson({ + name: "from-stdin", + db_pass: "stdin-secret", + organization_slug: "my-org", + }), + ), + unusedPlatformApiLayer, + BunServices.layer, ), ), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "", - data: expect.objectContaining({ - dryRun: true, - json: expect.objectContaining({ - name: "from-stdin", - db_pass: "<redacted>", + ); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "", + data: expect.objectContaining({ + dryRun: true, + json: expect.objectContaining({ + name: "from-stdin", + db_pass: "<redacted>", + }), }), }), - }), - ); - }); - - it("decodes --json input and projects response fields", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "json" }); - let capturedInput: unknown; - - const handler = runPlatformOperation({ - descriptor, - execute: (input) => - Effect.sync(() => { - capturedInput = input; - return { - id: "project-id", - ref: "abcd1234", - organization_id: "org-id", - organization_slug: "my-org", - name: "json-name", - region: "us-east-1", - created_at: "2026-03-13T10:00:00.000Z", - status: "ACTIVE_HEALTHY", - }; - }), - }); + ); + }), + ); + + it.live("decodes --json input and projects response fields", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "json" }); + let capturedInput: unknown; + + const handler = runPlatformOperation({ + descriptor, + execute: (input) => + Effect.sync(() => { + capturedInput = input; + return { + id: "project-id", + ref: "abcd1234", + organization_id: "org-id", + organization_slug: "my-org", + name: "json-name", + region: "us-east-1", + created_at: "2026-03-13T10:00:00.000Z", + status: "ACTIVE_HEALTHY", + }; + }), + }); - await Effect.runPromise( - handler({ + yield* handler({ params: Option.none(), json: Option.some( - JSON.stringify({ + encodeJson({ name: "json-name", db_pass: "json-password", organization_slug: "my-org", @@ -200,46 +208,45 @@ describe("projects create platform handler", () => { dryRun: false, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(capturedInput).toEqual({ - name: "json-name", - db_pass: "json-password", - organization_slug: "my-org", - }); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "", - data: { ref: "abcd1234", status: "ACTIVE_HEALTHY" }, - }), - ); - }); - - it("renders schema without executing the operation", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "json" }); - const stdio = mockStdio(); - let executed = false; - - const handler = runPlatformOperation({ - descriptor, - execute: (_input) => - Effect.sync(() => { - executed = true; - return { - id: "project-id", - }; + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(true), unusedPlatformApiLayer, BunServices.layer), + ), + ); + + expect(capturedInput).toEqual({ + name: "json-name", + db_pass: "json-password", + organization_slug: "my-org", + }); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "", + data: { ref: "abcd1234", status: "ACTIVE_HEALTHY" }, }), - }); + ); + }), + ); - await Effect.runPromise( - handler({ + it.live("renders schema without executing the operation", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "json" }); + const stdio = mockStdio(); + let executed = false; + + const handler = runPlatformOperation({ + descriptor, + execute: (_input) => + Effect.sync(() => { + executed = true; + return { + id: "project-id", + }; + }), + }); + + yield* handler({ params: Option.none(), json: Option.none(), body: Option.none(), @@ -250,48 +257,52 @@ describe("projects create platform handler", () => { dryRun: false, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(stdio.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(executed).toBe(false); - expect(out.messages).toEqual([]); - expect(JSON.parse(stdio.stdout.join(""))).toEqual( - expect.objectContaining({ - route: "/v1/projects", - method: "POST", - command: "supabase api request /v1/projects --method POST", - input: expect.objectContaining({ - body: expect.objectContaining({ - kind: "json", + Effect.provide( + Layer.mergeAll( + BunServices.layer, + unusedPlatformApiLayer, + mockStdin(true), + stdio.layer, + out.layer, + ), + ), + ); + + expect(executed).toBe(false); + expect(out.messages).toEqual([]); + expect(decodeJson(stdio.stdout.join(""))).toEqual( + expect.objectContaining({ + route: "/v1/projects", + method: "POST", + command: "supabase api request /v1/projects --method POST", + input: expect.objectContaining({ + body: expect.objectContaining({ + kind: "json", + }), }), }), - }), - ); - }); - - it("renders text schema output without a success banner", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "text" }); - let executed = false; - - const handler = runPlatformOperation({ - descriptor, - execute: () => - Effect.sync(() => { - executed = true; - return { - id: "project-id", - }; - }), - }); + ); + }), + ); + + it.live("renders text schema output without a success banner", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "text" }); + let executed = false; - await Effect.runPromise( - handler({ + const handler = runPlatformOperation({ + descriptor, + execute: () => + Effect.sync(() => { + executed = true; + return { + id: "project-id", + }; + }), + }); + + yield* handler({ params: Option.none(), json: Option.none(), body: Option.none(), @@ -302,45 +313,44 @@ describe("projects create platform handler", () => { dryRun: false, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(executed).toBe(false); - expect( - out.messages.some( - (message) => message.type === "success" && message.message === "Schema loaded.", - ), - ).toBe(false); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("Route\n POST /v1/projects"), - }), - ); - }); - - it("emits schema payloads as result events in stream-json mode", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "stream-json", interactive: false }); - let executed = false; - - const handler = runPlatformOperation({ - descriptor, - execute: () => - Effect.sync(() => { - executed = true; - return { - id: "project-id", - }; + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(true), unusedPlatformApiLayer, BunServices.layer), + ), + ); + + expect(executed).toBe(false); + expect( + out.messages.some( + (message) => message.type === "success" && message.message === "Schema loaded.", + ), + ).toBe(false); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("Route\n POST /v1/projects"), }), - }); + ); + }), + ); - await Effect.runPromise( - handler({ + it.live("emits schema payloads as result events in stream-json mode", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "stream-json", interactive: false }); + let executed = false; + + const handler = runPlatformOperation({ + descriptor, + execute: () => + Effect.sync(() => { + executed = true; + return { + id: "project-id", + }; + }), + }); + + yield* handler({ params: Option.none(), json: Option.none(), body: Option.none(), @@ -351,49 +361,48 @@ describe("projects create platform handler", () => { dryRun: false, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(executed).toBe(false); - expect(out.events).toContainEqual( - expect.objectContaining({ - type: "result", - data: expect.objectContaining({ - route: "/v1/projects", - method: "POST", - input: expect.objectContaining({ - body: expect.objectContaining({ - kind: "json", + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(true), unusedPlatformApiLayer, BunServices.layer), + ), + ); + + expect(executed).toBe(false); + expect(out.events).toContainEqual( + expect.objectContaining({ + type: "result", + data: expect.objectContaining({ + route: "/v1/projects", + method: "POST", + input: expect.objectContaining({ + body: expect.objectContaining({ + kind: "json", + }), }), }), }), - }), - ); - }); - - it("renders text dry-run previews without a success banner", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "text" }); - let executed = false; - - const handler = runPlatformOperation({ - descriptor, - execute: () => - Effect.sync(() => { - executed = true; - return { id: "project-id" }; - }), - }); + ); + }), + ); - await Effect.runPromise( - handler({ + it.live("renders text dry-run previews without a success banner", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "text" }); + let executed = false; + + const handler = runPlatformOperation({ + descriptor, + execute: () => + Effect.sync(() => { + executed = true; + return { id: "project-id" }; + }), + }); + + yield* handler({ params: Option.none(), json: Option.some( - JSON.stringify({ + encodeJson({ name: "preview-name", db_pass: "super-secret", organization_slug: "my-org", @@ -407,51 +416,50 @@ describe("projects create platform handler", () => { dryRun: true, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect(executed).toBe(false); - expect( - out.messages.some( - (message) => message.type === "success" && message.message === "Dry run complete.", - ), - ).toBe(false); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("db_pass: <redacted>"), - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("name: preview-name"), - }), - ); - }); - - it("omits the generic success banner for structured text responses", async () => { - const descriptor = findPlatformOperationDescriptor("v1ListAllOrganizations"); - const out = mockOutput({ format: "text" }); - - const handler = runPlatformOperation({ - descriptor, - execute: () => - Effect.sync(() => [ - { - id: "supabase", - slug: "supabase", - name: "Supabase", - }, - ]), - }); - - await Effect.runPromise( - handler({ + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(true), unusedPlatformApiLayer, BunServices.layer), + ), + ); + + expect(executed).toBe(false); + expect( + out.messages.some( + (message) => message.type === "success" && message.message === "Dry run complete.", + ), + ).toBe(false); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("db_pass: <redacted>"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("name: preview-name"), + }), + ); + }), + ); + + it.live("omits the generic success banner for structured text responses", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1ListAllOrganizations"); + const out = mockOutput({ format: "text" }); + + const handler = runPlatformOperation({ + descriptor, + execute: () => + Effect.sync(() => [ + { + id: "supabase", + slug: "supabase", + name: "Supabase", + }, + ]), + }); + + yield* handler({ params: Option.none(), json: Option.none(), body: Option.none(), @@ -462,34 +470,33 @@ describe("projects create platform handler", () => { dryRun: false, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(true)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), - ), - ); - - expect( - out.messages.some( - (message) => message.type === "success" && message.message === "Request completed.", - ), - ).toBe(false); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: expect.stringContaining("- id: supabase"), - }), - ); - }); - - it("returns a structured non-interactive error when required values are missing", async () => { - const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); - const out = mockOutput({ format: "json", interactive: false }); - - const handler = runPlatformOperation({ descriptor }); - - const exit = await Effect.runPromise( - handler({ + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(true), unusedPlatformApiLayer, BunServices.layer), + ), + ); + + expect( + out.messages.some( + (message) => message.type === "success" && message.message === "Request completed.", + ), + ).toBe(false); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: expect.stringContaining("- id: supabase"), + }), + ); + }), + ); + + it.live("returns a structured non-interactive error when required values are missing", () => + Effect.gen(function* () { + const descriptor = findPlatformOperationDescriptor("v1CreateAProject"); + const out = mockOutput({ format: "json", interactive: false }); + + const handler = runPlatformOperation({ descriptor }); + + const exit = yield* handler({ params: Option.none(), json: Option.none(), body: Option.none(), @@ -500,14 +507,13 @@ describe("projects create platform handler", () => { dryRun: false, yes: true, }).pipe( - Effect.provide(out.layer), - Effect.provide(mockStdin(false)), - Effect.provide(unusedPlatformApiLayer), - Effect.provide(BunServices.layer), + Effect.provide( + Layer.mergeAll(out.layer, mockStdin(false), unusedPlatformApiLayer, BunServices.layer), + ), Effect.exit, - ), - ); + ); - expect(Exit.isFailure(exit)).toBe(true); - }); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); }); diff --git a/apps/cli/src/next/commands/platform/request.command.ts b/apps/cli/src/next/commands/platform/request.command.ts index e6f13357b1..79ee2bd263 100644 --- a/apps/cli/src/next/commands/platform/request.command.ts +++ b/apps/cli/src/next/commands/platform/request.command.ts @@ -66,10 +66,8 @@ const config = { } as const; const requestPlatformApiLayer = platformApiLayer.pipe(Layer.provide(credentialsLayer)); -const requestRuntimeLayer = Layer.mergeAll( - requestPlatformApiLayer, - stdinLayer, - commandRuntimeLayer(["api", "request"]), +const requestRuntimeLayer = Layer.mergeAll(requestPlatformApiLayer, stdinLayer).pipe( + Layer.provideMerge(commandRuntimeLayer(["api", "request"])), ); function resolveDescriptor(route: string, method: Option.Option<PlatformHttpMethod>) { @@ -117,7 +115,7 @@ export const apiRequestCommand = Command.make("request", config).pipe( resolved instanceof PlatformRouteNotFoundError || resolved instanceof PlatformMethodSelectionError ) { - return yield* Effect.fail(resolved); + return yield* resolved; } return yield* runPlatformOperation({ descriptor: resolved })({ diff --git a/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts b/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts index 6d6873adf8..a3077ed73c 100644 --- a/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts +++ b/apps/cli/src/next/commands/start/flows/foreground.flow.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/new-promise -- this test injects a deliberately gated foreign Promise to verify shutdown coordination. import { describe, expect, it } from "@effect/vitest"; import { StackUnavailableError } from "@supabase/stack/effect"; import { makeTestStack } from "@supabase/stack/testing"; diff --git a/apps/cli/src/next/commands/start/flows/foreground.flow.ts b/apps/cli/src/next/commands/start/flows/foreground.flow.ts index 05b3c37f7b..98769b6ce2 100644 --- a/apps/cli/src/next/commands/start/flows/foreground.flow.ts +++ b/apps/cli/src/next/commands/start/flows/foreground.flow.ts @@ -4,7 +4,7 @@ import { interruptOnSignal } from "../signal.ts"; import { makeStartForegroundSession } from "../ui/foreground-session.ts"; export const startForegroundWithStopSignal = <R>(stopRequested: Effect.Effect<void, never, R>) => - Effect.fnUntraced(function* () { + Effect.gen(function* () { const stack = yield* Stack; return yield* Effect.scoped( @@ -27,7 +27,7 @@ export const startForegroundWithStopSignal = <R>(stopRequested: Effect.Effect<vo ); }), ); - })(); + }); export const startForeground = Effect.fnUntraced(function* () { return yield* startForegroundWithStopSignal(interruptOnSignal); diff --git a/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts b/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts index 8018911356..02df3060ca 100644 --- a/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts +++ b/apps/cli/src/next/commands/start/flows/non-interactive.flow.ts @@ -14,11 +14,8 @@ export const startNonInteractive = Effect.fnUntraced(function* () { yield* stack .allStateChanges() .pipe(Stream.runForEach((state) => output.info(`${state.name}: ${state.status}`))); - }) - .pipe(Effect.raceFirst(interruptOnSignal)) - .pipe( - Effect.ensuring( - Effect.uninterruptible(stack.dispose().pipe(Effect.catch(() => Effect.void))), - ), - ); + }).pipe( + Effect.raceFirst(interruptOnSignal), + Effect.ensuring(Effect.uninterruptible(stack.dispose().pipe(Effect.ignore))), + ); }); diff --git a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts index 65658df4de..860041345b 100644 --- a/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts +++ b/apps/cli/src/next/commands/start/service-version-overrides.unit.test.ts @@ -1,5 +1,5 @@ import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { mockProjectLinkState, @@ -11,19 +11,22 @@ import { } from "../../config/service-version-resolution.ts"; describe("service version overrides", () => { - test("canonicalizes repeated flag overrides to published service tags", async () => { - await expect( - Effect.runPromise( - parseServiceVersionOverrides(["postgrest=v14.5", "mailpit=1.30.2", "auth=2.180.0"]), - ), - ).resolves.toEqual({ - postgrest: "v14.5", - mailpit: "v1.30.2", - auth: "v2.180.0", - }); - }); + it.effect("canonicalizes repeated flag overrides to published service tags", () => + Effect.gen(function* () { + const result = yield* parseServiceVersionOverrides([ + "postgrest=v14.5", + "mailpit=1.30.2", + "auth=2.180.0", + ]); + expect(result).toEqual({ + postgrest: "v14.5", + mailpit: "v1.30.2", + auth: "v2.180.0", + }); + }), + ); - test("resolves flag > local file > link state precedence", async () => { + it.effect("resolves flag > local file > link state precedence", () => { const candidateBaseline = { ...DEFAULT_VERSIONS, postgres: "17.6.1.090", @@ -56,28 +59,25 @@ describe("service version overrides", () => { }), ); - await expect( - Effect.runPromise( - resolveServiceVersionContext(["auth=v2.170.0", "postgres=17.4.1.045"]).pipe( - Effect.provide(layer), - ), - ), - ).resolves.toEqual({ - candidateBaseline, - pinnedBaseline: candidateBaseline, - runtimeVersions: { - ...candidateBaseline, - postgres: "17.4.1.045", - auth: "v2.170.0", - storage: "v1.40.0", - }, - activeOverrides: [ - { service: "postgres", version: "17.4.1.045", source: "flag" }, - { service: "auth", version: "v2.170.0", source: "flag" }, - { service: "storage", version: "v1.40.0", source: "local" }, - ], - availableUpdates: [], - updateFingerprint: undefined, - }); + return Effect.gen(function* () { + const result = yield* resolveServiceVersionContext(["auth=v2.170.0", "postgres=17.4.1.045"]); + expect(result).toEqual({ + candidateBaseline, + pinnedBaseline: candidateBaseline, + runtimeVersions: { + ...candidateBaseline, + postgres: "17.4.1.045", + auth: "v2.170.0", + storage: "v1.40.0", + }, + activeOverrides: [ + { service: "postgres", version: "17.4.1.045", source: "flag" }, + { service: "auth", version: "v2.170.0", source: "flag" }, + { service: "storage", version: "v1.40.0", source: "local" }, + ], + availableUpdates: [], + updateFingerprint: undefined, + }); + }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index abad14771f..c9f02940cd 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -13,6 +13,7 @@ import type * as CliCommand from "effect/unstable/cli/Command"; import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts"; import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { CliConfig } from "../../config/cli-config.service.ts"; +import { ProjectContext } from "../../config/project-context.service.ts"; import { ProjectHome } from "../../config/project-home.service.ts"; import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts"; import { provideProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; @@ -195,6 +196,7 @@ export const startCommand = Command.make("start", flags).pipe( const runtimeStateEffect = Effect.gen(function* () { const output = yield* Output; const cliConfig = yield* CliConfig; + const projectContext = yield* ProjectContext; const projectHome = yield* ProjectHome; const runtimeInfo = yield* RuntimeInfo; const existingSummary = yield* resolveStackSummary({ @@ -202,7 +204,7 @@ export const startCommand = Command.make("start", flags).pipe( projectDir: projectHome.projectRoot, cwd: runtimeInfo.cwd, name: flags.stack, - }).pipe(Effect.catchTag("NoRunningStackError", () => Effect.succeed(undefined))); + }).pipe(Effect.catchTag("NoRunningStackError", () => Effect.void)); const serviceVersionContext = yield* resolveServiceVersionContext( flags.serviceVersion, existingSummary === undefined @@ -213,7 +215,9 @@ export const startCommand = Command.make("start", flags).pipe( // unset behaves as false (revoke the default Data API GRANTs) to match the new cloud // default. Explicit true preserves the legacy auto-expose behaviour but is deprecated and // emits a warning; the field is removed entirely on 2026-10-30. - const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); + const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot, { + projectEnv: Option.getOrUndefined(projectContext.projectEnv), + }); const { autoExposeNewTables, deprecationWarning } = resolveAutoExposeNewTables( loadedProjectConfig?.config.api.auto_expose_new_tables, ); diff --git a/apps/cli/src/next/commands/start/start.command.unit.test.ts b/apps/cli/src/next/commands/start/start.command.unit.test.ts index e326938db5..6976cbb870 100644 --- a/apps/cli/src/next/commands/start/start.command.unit.test.ts +++ b/apps/cli/src/next/commands/start/start.command.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; import { @@ -9,63 +9,55 @@ import { } from "./start.command.ts"; describe("start command exclude flag", () => { - test("parses repeated excluded services", async () => { - const [, exclude] = await Effect.runPromise( - excludeFlag - .parse({ - flags: { exclude: ["auth", "postgrest"] }, - arguments: [], - }) - .pipe(Effect.provide(BunServices.layer)), - ); + it.effect("parses repeated excluded services", () => + Effect.gen(function* () { + const [, exclude] = yield* excludeFlag.parse({ + flags: { exclude: ["auth", "postgrest"] }, + arguments: [], + }); + expect(exclude).toEqual(["auth", "postgrest"]); + }).pipe(Effect.provide(BunServices.layer)), + ); - expect(exclude).toEqual(["auth", "postgrest"]); - }); - - test("rejects invalid excluded services", async () => { - const exit = await Effect.runPromise( - excludeFlag + it.effect("rejects invalid excluded services", () => + Effect.gen(function* () { + const exit = yield* excludeFlag .parse({ flags: { exclude: ["postgres"] }, arguments: [], }) - .pipe(Effect.provide(BunServices.layer)) - .pipe(Effect.exit), - ); - - expect(Exit.isFailure(exit)).toBe(true); - }); - - test("parses repeated service version overrides", async () => { - const [, overrides] = await Effect.runPromise( - serviceVersionFlag - .parse({ - flags: { "service-version": ["auth=v2.180.0", "postgres=17.4.1.045"] }, - arguments: [], - }) - .pipe(Effect.provide(BunServices.layer)), - ); + .pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(BunServices.layer)), + ); - expect(overrides).toEqual(["auth=v2.180.0", "postgres=17.4.1.045"]); - }); + it.effect("parses repeated service version overrides", () => + Effect.gen(function* () { + const [, overrides] = yield* serviceVersionFlag.parse({ + flags: { "service-version": ["auth=v2.180.0", "postgres=17.4.1.045"] }, + arguments: [], + }); + expect(overrides).toEqual(["auth=v2.180.0", "postgres=17.4.1.045"]); + }).pipe(Effect.provide(BunServices.layer)), + ); }); describe("resolveAutoExposeNewTables", () => { - test("defaults to false (revoke) when the flag is unset", () => { + it("defaults to false (revoke) when the flag is unset", () => { expect(resolveAutoExposeNewTables(undefined)).toEqual({ autoExposeNewTables: false, deprecationWarning: undefined, }); }); - test("keeps legacy auto-expose behaviour and warns when explicitly true", () => { + it("keeps legacy auto-expose behaviour and warns when explicitly true", () => { expect(resolveAutoExposeNewTables(true)).toEqual({ autoExposeNewTables: true, deprecationWarning: AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING, }); }); - test("revokes without warning when explicitly false", () => { + it("revokes without warning when explicitly false", () => { expect(resolveAutoExposeNewTables(false)).toEqual({ autoExposeNewTables: false, deprecationWarning: undefined, diff --git a/apps/cli/src/next/commands/start/start.e2e.test.ts b/apps/cli/src/next/commands/start/start.e2e.test.ts index 898331795e..d5f4c041c4 100644 --- a/apps/cli/src/next/commands/start/start.e2e.test.ts +++ b/apps/cli/src/next/commands/start/start.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch, effecttsgo/node-builtin-import -- this e2e test drives a compiled CLI and real HTTP endpoint. import { mkdir, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { afterEach, describe, expect, test } from "vitest"; diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index 833dd03444..cac01a824f 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- this integration test injects a Promise-based foreign stack factory. import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { diff --git a/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts b/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts index 331009b352..c770da3cfb 100644 --- a/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts +++ b/apps/cli/src/next/commands/start/ui/StartDashboardView.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { StackServiceState, type StackServiceStatus } from "@supabase/stack/effect"; import { ConnectionInfo } from "./ConnectionInfo.tsx"; +import { StartDashboardView } from "./StartDashboard.tsx"; function state(name: string, status: StackServiceStatus, error: string | null = null) { return new StackServiceState({ @@ -32,12 +33,8 @@ function collectNodes(node: unknown): Array<unknown> { } describe("StartDashboardView", () => { - test("renders the starting status without connection info", async () => { - const dashboardModule = await import("./StartDashboard.tsx"); - expect("StartDashboardView" in dashboardModule).toBe(true); - if (!("StartDashboardView" in dashboardModule)) return; - - const element = dashboardModule.StartDashboardView({ + test("renders the starting status without connection info", () => { + const element = StartDashboardView({ states: [state("postgres", "Starting")], info: null, showConnectionInfo: false, @@ -55,12 +52,8 @@ describe("StartDashboardView", () => { ).toBe(false); }, 15_000); - test("renders the failed status without connection info", async () => { - const dashboardModule = await import("./StartDashboard.tsx"); - expect("StartDashboardView" in dashboardModule).toBe(true); - if (!("StartDashboardView" in dashboardModule)) return; - - const element = dashboardModule.StartDashboardView({ + test("renders the failed status without connection info", () => { + const element = StartDashboardView({ states: [state("postgres", "Failed", "Health check failed and restart budget was exhausted")], info: { url: "http://127.0.0.1:54321", diff --git a/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts b/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts index 2bcbcb6193..e577bb8e0b 100644 --- a/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts +++ b/apps/cli/src/next/commands/start/ui/dashboard.model.unit.test.ts @@ -3,6 +3,7 @@ import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; import { Effect, Layer, SubscriptionRef } from "effect"; import { StackServiceState, type StackInfo, type StackServiceStatus } from "@supabase/stack/effect"; import { StartDashboardState } from "./dashboard-state.ts"; +import { createStartDashboardModel } from "./dashboard.model.ts"; function state(name: string, status: StackServiceStatus) { return new StackServiceState({ @@ -40,12 +41,8 @@ describe("createStartDashboardModel", () => { }), ); - test("creates dashboard-scoped writable and derived atoms", async () => { - const modelModule = await import("./dashboard.model.ts"); - expect("createStartDashboardModel" in modelModule).toBe(true); - if (!("createStartDashboardModel" in modelModule)) return; - - const model = modelModule.createStartDashboardModel(dashboardStateLayer); + test("creates dashboard-scoped writable and derived atoms", () => { + const model = createStartDashboardModel(dashboardStateLayer); const registry = AtomRegistry.make(); expect(registry.get(model.stackInfoAtom)).toBeNull(); @@ -72,12 +69,8 @@ describe("createStartDashboardModel", () => { expect(registry.get(model.showConnectionInfoAtom)).toBe(true); }); - test("shows the foreground failure message when startup fails", async () => { - const modelModule = await import("./dashboard.model.ts"); - expect("createStartDashboardModel" in modelModule).toBe(true); - if (!("createStartDashboardModel" in modelModule)) return; - - const model = modelModule.createStartDashboardModel(dashboardStateLayer); + test("shows the foreground failure message when startup fails", () => { + const model = createStartDashboardModel(dashboardStateLayer); const registry = AtomRegistry.make(); registry.set(model.errorAtom, "startup failed"); diff --git a/apps/cli/src/next/commands/start/ui/foreground-session.ts b/apps/cli/src/next/commands/start/ui/foreground-session.ts index 3433c20e24..3e86b3c134 100644 --- a/apps/cli/src/next/commands/start/ui/foreground-session.ts +++ b/apps/cli/src/next/commands/start/ui/foreground-session.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-timers -- AtomRegistry requires a callback scheduler, not an Effect value. import { clearTimeout, setTimeout } from "node:timers"; import { createElement } from "react"; import * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry"; diff --git a/apps/cli/src/next/commands/status/status.handler.ts b/apps/cli/src/next/commands/status/status.handler.ts index b05dc21ee5..24788fa875 100644 --- a/apps/cli/src/next/commands/status/status.handler.ts +++ b/apps/cli/src/next/commands/status/status.handler.ts @@ -8,6 +8,7 @@ import { type StackSummary, } from "@supabase/stack/effect"; import { CliConfig } from "../../config/cli-config.service.ts"; +import { ProjectContext } from "../../config/project-context.service.ts"; import { ProjectHome } from "../../config/project-home.service.ts"; import { resolveServiceVersionContext } from "../../config/service-version-resolution.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -87,7 +88,10 @@ const resolveConfiguredSummary = Effect.fnUntraced(function* (input: { readonly name: string; }) { const current = yield* resolveStackSummary(input); - const loaded = yield* loadProjectConfig(input.projectDir); + const projectContext = yield* ProjectContext; + const loaded = yield* loadProjectConfig(input.projectDir, { + projectEnv: Option.getOrUndefined(projectContext.projectEnv), + }); const excluded = (current.launch.excludedServices ?? []).filter(isExcludedStackService); const mode = current.launch.mode; return yield* resolveStackSummary({ @@ -148,10 +152,10 @@ export const status = Effect.fnUntraced(function* (_flags: StatusFlags) { name: _flags.stack, }).pipe( Effect.map((layer) => ({ _tag: "live" as const, layer })), - Effect.catchTag("DaemonUpgradeRequired", (error) => - Effect.succeed({ _tag: "upgrade" as const, error }), - ), - Effect.catchTag("NoRunningStackError", () => Effect.succeed({ _tag: "none" as const })), + Effect.catchTags({ + DaemonUpgradeRequired: (error) => Effect.succeed({ _tag: "upgrade" as const, error }), + NoRunningStackError: () => Effect.succeed({ _tag: "none" as const }), + }), ); if (Predicate.isTagged(layerResult, "upgrade")) { diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index 9eef4a5d59..d609efb994 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- this integration test asserts exact persisted host files. import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; @@ -5,6 +6,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { status } from "./status.handler.ts"; import { + mockProjectContext, mockOutput, mockProjectLinkState, mockProjectLocalServiceVersions, @@ -29,6 +31,7 @@ describe("status handler", () => { const out = mockOutput(); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(), mockProjectLocalServiceVersions(), @@ -84,6 +87,7 @@ describe("status handler", () => { const out = mockOutput(); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(), mockProjectLocalServiceVersions(), @@ -119,6 +123,7 @@ describe("status handler", () => { const out = mockOutput(); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(), mockProjectLocalServiceVersions(), @@ -172,6 +177,7 @@ describe("status handler", () => { const out = mockOutput({ format: "json", interactive: false }); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(), mockProjectLocalServiceVersions(), @@ -215,6 +221,7 @@ describe("status handler", () => { const out = mockOutput({ format: "json", interactive: false }); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(), mockProjectLocalServiceVersions(), @@ -255,6 +262,7 @@ describe("status handler", () => { const out = mockOutput(); const layer = Layer.mergeAll( fixture.baseLayer, + mockProjectContext(), out.layer, mockProjectLinkState(), mockProjectLocalServiceVersions(), diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index 2e4f471ba7..32c39ec11f 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, FileSystem, Layer } from "effect"; -import { existsSync } from "node:fs"; import { stop } from "./stop.handler.ts"; import { managedStackDocumentPathEffect } from "@supabase/stack/managed"; import { mockOutput, mockProjectLinkState } from "../../../../tests/helpers/mocks.ts"; @@ -21,15 +20,16 @@ describe("stop handler", () => { BunServices.layer, ); return stop({ stack: fixture.stackName, noBackup: false }).pipe( - Effect.provide(layer), Effect.tap( - Effect.sync(() => { + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", message: "Local Supabase stopped" }), ); - expect(existsSync(fixture.stateRoot)).toBe(true); + expect(yield* fs.exists(fixture.stateRoot)).toBe(true); }), ), + Effect.provide(layer), Effect.ensuring(Effect.promise(() => fixture.dispose())), ); }), diff --git a/apps/cli/src/next/commands/telemetry/telemetry.command.ts b/apps/cli/src/next/commands/telemetry/telemetry.command.ts index 48f07b80cd..340b522c8e 100644 --- a/apps/cli/src/next/commands/telemetry/telemetry.command.ts +++ b/apps/cli/src/next/commands/telemetry/telemetry.command.ts @@ -1,4 +1,4 @@ -import { Effect, Option } from "effect"; +import { Clock, Effect, Option } from "effect"; import { Command } from "effect/unstable/cli"; import { Output } from "../../../shared/output/output.service.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -16,13 +16,14 @@ const enableTelemetry = Effect.gen(function* () { const output = yield* Output; const configDir = yield* getConfigDir; const identity = yield* resolveIdentity(configDir); + const now = yield* Clock.currentTimeMillis; yield* writeTelemetryConfig( { consent: "granted", device_id: identity.deviceId, session_id: identity.sessionId, - session_last_active: Date.now(), + session_last_active: now, ...(identity.distinctId === undefined ? {} : { distinct_id: identity.distinctId }), }, configDir, @@ -34,13 +35,14 @@ const disableTelemetry = Effect.gen(function* () { const output = yield* Output; const configDir = yield* getConfigDir; const identity = yield* resolveIdentity(configDir); + const now = yield* Clock.currentTimeMillis; yield* writeTelemetryConfig( { consent: "denied", device_id: identity.deviceId, session_id: identity.sessionId, - session_last_active: Date.now(), + session_last_active: now, ...(identity.distinctId === undefined ? {} : { distinct_id: identity.distinctId }), }, configDir, diff --git a/apps/cli/src/next/commands/unlink/unlink.integration.test.ts b/apps/cli/src/next/commands/unlink/unlink.integration.test.ts index 8f16237686..d807829251 100644 --- a/apps/cli/src/next/commands/unlink/unlink.integration.test.ts +++ b/apps/cli/src/next/commands/unlink/unlink.integration.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; import { cliConfigLayer } from "../../config/cli-config.layer.ts"; import { projectContextLayer } from "../../config/project-context.layer.ts"; @@ -14,14 +10,12 @@ import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts" import { ProjectLinkState } from "../../config/project-link-state.service.ts"; import { unlink } from "./unlink.handler.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-unlink-command-")); -} - -function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { +function buildLayer(opts: { cwd: string; path: Path.Path; env?: Record<string, string> }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.env?.SUPABASE_HOME ? join(opts.env.SUPABASE_HOME, "..") : join(opts.cwd, ".home"), + homeDir: opts.env?.SUPABASE_HOME + ? opts.path.join(opts.env.SUPABASE_HOME, "..") + : opts.path.join(opts.cwd, ".home"), }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredProjectContextLayer = projectContextLayer.pipe( @@ -32,6 +26,7 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { const discoveredCliConfigLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(discoveredProjectContextLayer), + Layer.provideMerge(BunServices.layer), ); const discoveredProjectHomeLayer = projectHomeLayer.pipe( Layer.provide(BunServices.layer), @@ -62,90 +57,94 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { describe("unlink handler", () => { it.live("clears only cached link state and leaves project config unchanged", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); const projectRef = "abcdefghijklmnopqrst"; const initialConfig = `project_id = "${projectRef}"\n`; - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), initialConfig), - ); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-unlink-command-" }); + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), initialConfig); - const { layer, out } = buildLayer({ - cwd: projectRoot, - env: { SUPABASE_HOME: supabaseHome }, - }); - const { projectHome, linkState } = yield* Effect.gen(function* () { - return { - projectHome: yield* ProjectHome, - linkState: yield* ProjectLinkState, - }; - }).pipe(Effect.provide(layer)); + const { layer, out } = buildLayer({ + cwd: projectRoot, + path, + env: { SUPABASE_HOME: supabaseHome }, + }); + const { projectHome, linkState } = yield* Effect.gen(function* () { + return { + projectHome: yield* ProjectHome, + linkState: yield* ProjectLinkState, + }; + }).pipe(Effect.provide(layer)); - yield* projectHome.ensureProjectHomeDir; - yield* linkState.save({ - project: { - ref: projectRef, - name: "Linked Project", - organization_id: "org_123", - organization_slug: "supabase", - }, - active_branch: { ref: projectRef, name: "main", is_default: true }, - fetchedAt: "2026-03-20T12:00:00.000Z", - versions: { postgres: "17.6.1.090" }, - }); + yield* projectHome.ensureProjectHomeDir; + yield* linkState.save({ + project: { + ref: projectRef, + name: "Linked Project", + organization_id: "org_123", + organization_slug: "supabase", + }, + active_branch: { ref: projectRef, name: "main", is_default: true }, + fetchedAt: "2026-03-20T12:00:00.000Z", + versions: { postgres: "17.6.1.090" }, + }); - yield* unlink().pipe(Effect.provide(layer)); + yield* unlink().pipe(Effect.provide(layer)); - const configContent = yield* Effect.tryPromise(() => - readFile(join(projectRoot, "supabase", "config.toml"), "utf8"), - ); - expect(configContent).toBe(initialConfig); + const configContent = yield* fs.readFileString( + path.join(projectRoot, "supabase", "config.toml"), + ); + expect(configContent).toBe(initialConfig); - const cached = yield* linkState.load; - expect(Option.isNone(cached)).toBe(true); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Local project unlinked." }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "outro", - message: `Unlinked local project from Linked Project (${projectRef}).`, - }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const cached = yield* linkState.load; + expect(Option.isNone(cached)).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "Local project unlinked." }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: `Unlinked local project from Linked Project (${projectRef}).`, + }), + ); + }), + ).pipe(Effect.provide(BunServices.layer)); }); it.live("succeeds without requiring a local Supabase config", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(projectRoot, { recursive: true })); + return Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-unlink-command-" }); + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(projectRoot, { recursive: true }); - const { layer, out } = buildLayer({ - cwd: projectRoot, - env: { SUPABASE_HOME: supabaseHome }, - }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); + const { layer, out } = buildLayer({ + cwd: projectRoot, + path, + env: { SUPABASE_HOME: supabaseHome }, + }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); - yield* unlink().pipe(Effect.provide(layer)); + yield* unlink().pipe(Effect.provide(layer)); - const cached = yield* linkState.load; - expect(Option.isNone(cached)).toBe(true); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "success", message: "Local project is already unlinked." }), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const cached = yield* linkState.load; + expect(Option.isNone(cached)).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Local project is already unlinked.", + }), + ); + }), + ).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/commands/update/update.command.ts b/apps/cli/src/next/commands/update/update.command.ts index e9cf1bea15..98524a677e 100644 --- a/apps/cli/src/next/commands/update/update.command.ts +++ b/apps/cli/src/next/commands/update/update.command.ts @@ -36,8 +36,7 @@ const updateRuntimeLayer = provideProjectCommandRuntime( projectLinkStateLayer, projectLocalServiceVersionsLayer, updateProjectLinkRemoteLayer, - commandRuntimeLayer(["stack", "update"]), - ), + ).pipe(Layer.provideMerge(commandRuntimeLayer(["stack", "update"]))), ); export const updateCommand = Command.make("update", flags).pipe( diff --git a/apps/cli/src/next/commands/update/update.integration.test.ts b/apps/cli/src/next/commands/update/update.integration.test.ts index aafe19c9b0..10ca120a8d 100644 --- a/apps/cli/src/next/commands/update/update.integration.test.ts +++ b/apps/cli/src/next/commands/update/update.integration.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer } from "effect"; -import { rmSync } from "node:fs"; -import { join } from "node:path"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { DEFAULT_VERSIONS } from "@supabase/stack/effect"; import { update } from "./update.handler.ts"; import { @@ -29,32 +27,23 @@ describe("update handler", () => { mockProjectLocalServiceVersions(), BunServices.layer, ); - return update({ stack: fixture.stackName }).pipe( - Effect.provide(layer), - Effect.tap( - Effect.promise(async () => { - const document = await fixture.readDocument(); - expect(document?.launch?.versions).toEqual(DEFAULT_VERSIONS); + return Effect.gen(function* () { + yield* update({ stack: fixture.stackName }); + const document = yield* Effect.promise(() => fixture.readDocument()); + expect(document?.launch?.versions).toEqual(DEFAULT_VERSIONS); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Updated pinned local stack versions.", }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Updated pinned local stack versions.", - }), - ); - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "info", - message: expect.stringContaining("postgres:"), - }), - ); + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("postgres:"), }), - ), - ); + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.promise(() => fixture.dispose()))); }), ), ); @@ -62,10 +51,6 @@ describe("update handler", () => { it.live("prepares versions before the first managed stack start", () => Effect.promise(() => makeStoppedStackFixture()).pipe( Effect.flatMap((fixture) => { - rmSync(join(fixture.stateRoot, "stacks", fixture.stackId), { - recursive: true, - force: true, - }); const out = mockOutput(); const layer = Layer.mergeAll( fixture.baseLayer, @@ -75,25 +60,22 @@ describe("update handler", () => { mockProjectLocalServiceVersions(), BunServices.layer, ); - return update({ stack: fixture.stackName }).pipe( - Effect.provide(layer), - Effect.tap( - Effect.promise(async () => { - expect(await fixture.readDocument()).toBeUndefined(); - }), - ), - Effect.ensuring(Effect.promise(() => fixture.dispose())), - Effect.andThen( - Effect.sync(() => { - expect(out.messages).toContainEqual( - expect.objectContaining({ - type: "success", - message: "Pinned stack versions are already up to date.", - }), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(path.join(fixture.stateRoot, "stacks", fixture.stackId), { + recursive: true, + force: true, + }); + yield* update({ stack: fixture.stackName }); + expect(yield* Effect.promise(() => fixture.readDocument())).toBeUndefined(); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Pinned stack versions are already up to date.", }), - ), - ); + ); + }).pipe(Effect.provide(layer), Effect.ensuring(Effect.promise(() => fixture.dispose()))); }), ), ); diff --git a/apps/cli/src/next/config/cli-config.layer.ts b/apps/cli/src/next/config/cli-config.layer.ts index 79e823f914..443702a9ca 100644 --- a/apps/cli/src/next/config/cli-config.layer.ts +++ b/apps/cli/src/next/config/cli-config.layer.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Option, Redacted } from "effect"; +import { Config, Effect, Layer, Option, Path, Redacted } from "effect"; import { resolveSupabaseHome } from "../../shared/config/supabase-home.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { resolvePosthogConfig } from "../../shared/telemetry/posthog-config.ts"; @@ -9,6 +9,23 @@ const SUPABASE_API_URL = "https://api.supabase.com"; const SUPABASE_DASHBOARD_URL = "https://supabase.com/dashboard"; const SUPABASE_PROJECT_HOST = "supabase.co"; +const CLI_ENV_KEYS = [ + "SUPABASE_API_URL", + "SUPABASE_DASHBOARD_URL", + "SUPABASE_PROJECT_HOST", + "SUPABASE_ACCESS_TOKEN", + "SUPABASE_NO_KEYRING", + "SUPABASE_HOME", + "SUPABASE_DEBUG", + "SUPABASE_TELEMETRY_DEBUG", + "SUPABASE_TELEMETRY_DISABLED", + "DO_NOT_TRACK", + "SUPABASE_TELEMETRY_POSTHOG_HOST", + "SUPABASE_TELEMETRY_POSTHOG_KEY", + "SUPABASE_CLI_POSTHOG_HOST", + "SUPABASE_CLI_POSTHOG_KEY", +] as const; + function readEnv( env: Readonly<Record<string, string | undefined>>, key: string, @@ -19,12 +36,23 @@ function readEnv( const makeCliConfig = Effect.gen(function* () { const runtimeInfo = yield* RuntimeInfo; + const path = yield* Path.Path; const projectContext = yield* ProjectContext; - const effectiveEnv = Option.match(projectContext.projectEnv, { - onNone: () => process.env, - onSome: (projectEnv) => projectEnv.values, - }); + let effectiveEnv: Readonly<Record<string, string | undefined>>; + if (Option.isSome(projectContext.projectEnv)) { + effectiveEnv = projectContext.projectEnv.value.values; + } else { + const entries = yield* Effect.all( + CLI_ENV_KEYS.map((key) => + Config.option(Config.string(key)).pipe(Effect.map((value) => ({ key, value }))), + ), + ); + effectiveEnv = Object.fromEntries( + entries.flatMap(({ key, value }) => (Option.isSome(value) ? [[key, value.value]] : [])), + ); + } const posthogConfig = resolvePosthogConfig(effectiveEnv); + const configuredHome = readEnv(effectiveEnv, "SUPABASE_HOME"); return CliConfig.of({ apiUrl: Option.getOrElse(readEnv(effectiveEnv, "SUPABASE_API_URL"), () => SUPABASE_API_URL), @@ -42,7 +70,11 @@ const makeCliConfig = Effect.gen(function* () { Redacted.make(token, { label: "SUPABASE_ACCESS_TOKEN" }), ), noKeyring: readEnv(effectiveEnv, "SUPABASE_NO_KEYRING"), - supabaseHome: resolveSupabaseHome(effectiveEnv, runtimeInfo.homeDir), + supabaseHome: resolveSupabaseHome( + path, + Option.getOrUndefined(configuredHome), + runtimeInfo.homeDir, + ), debug: readEnv(effectiveEnv, "SUPABASE_DEBUG"), telemetryDebug: readEnv(effectiveEnv, "SUPABASE_TELEMETRY_DEBUG"), telemetryDisabled: readEnv(effectiveEnv, "SUPABASE_TELEMETRY_DISABLED"), diff --git a/apps/cli/src/next/config/cli-config.layer.unit.test.ts b/apps/cli/src/next/config/cli-config.layer.unit.test.ts index a1cce8f6c8..f056653e11 100644 --- a/apps/cli/src/next/config/cli-config.layer.unit.test.ts +++ b/apps/cli/src/next/config/cli-config.layer.unit.test.ts @@ -1,24 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option, Redacted } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { CliConfig } from "./cli-config.service.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { ProjectContext } from "./project-context.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-cli-config-")); -} - -function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: string }) { +function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir: string }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir, }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredProjectContextLayer = projectContextLayer.pipe( @@ -29,215 +21,235 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: const discoveredCliConfigLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(discoveredProjectContextLayer), + Layer.provide(envLayer), ); - return Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - envLayer, - discoveredProjectContextLayer, - discoveredCliConfigLayer, - ); + return Layer.mergeAll(discoveredProjectContextLayer, discoveredCliConfigLayer); } describe("cliConfigLayer", () => { it.live("falls back to ambient env when no Supabase project is found", () => { - const tempDir = makeTempDir(); return Effect.gen(function* () { - const cliConfig = yield* CliConfig; - const projectContext = yield* ProjectContext; - - expect(cliConfig.apiUrl).toBe("https://ambient.example"); - expect(Option.isNone(projectContext.paths)).toBe(true); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_API_URL: "https://ambient.example", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const projectContext = yield* ProjectContext; + + expect(cliConfig.apiUrl).toBe("https://ambient.example"); + expect(Option.isNone(projectContext.paths)).toBe(true); + }).pipe( + Effect.provide( + buildLayer({ + cwd: tempDir, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_API_URL: "https://ambient.example" }, + }), + ), + Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore)), + ); + }).pipe(Effect.provide(BunServices.layer)); }); it.live( "uses the nearest discovered project and loads supabase/.env.local over supabase/.env", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const cwd = join(packageRoot, "src"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(repoRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(join(packageRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), - ); - yield* Effect.tryPromise(() => - writeFile(join(repoRoot, "supabase", ".env"), "SUPABASE_API_URL=https://repo.example\n"), - ); - yield* Effect.tryPromise(() => - writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'), - ); - yield* Effect.tryPromise(() => - writeFile( - join(packageRoot, "supabase", ".env"), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const repoRoot = path.join(tempDir, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(packageRoot, "src"); + + yield* fs.makeDirectory(path.join(repoRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", ".env"), + "SUPABASE_API_URL=https://repo.example\n", + ); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', + ); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env"), "SUPABASE_API_URL=https://shared.example\nSUPABASE_DASHBOARD_URL=https://dashboard.example\n", - ), - ); - yield* Effect.tryPromise(() => - writeFile( - join(packageRoot, "supabase", ".env.local"), + ); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env.local"), "SUPABASE_API_URL=https://local.example\n", - ), - ); - - const { cliConfig, projectContext } = yield* Effect.gen(function* () { - return { - cliConfig: yield* CliConfig, - projectContext: yield* ProjectContext, - }; - }).pipe(Effect.provide(buildLayer({ cwd }))); - - expect(cliConfig.apiUrl).toBe("https://local.example"); - expect(cliConfig.dashboardUrl).toBe("https://dashboard.example"); - expect(Option.isSome(projectContext.paths)).toBe(true); - if (Option.isSome(projectContext.paths)) { - expect(projectContext.paths.value.projectRoot).toBe(packageRoot); - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + ); + + const cliConfig = yield* CliConfig.pipe( + Effect.provide(buildLayer({ cwd, homeDir: path.join(tempDir, ".home") })), + ); + const projectContext = yield* ProjectContext.pipe( + Effect.provide(buildLayer({ cwd, homeDir: path.join(tempDir, ".home") })), + ); + + expect(cliConfig.apiUrl).toBe("https://local.example"); + expect(cliConfig.dashboardUrl).toBe("https://dashboard.example"); + expect(Option.isSome(projectContext.paths)).toBe(true); + if (Option.isSome(projectContext.paths)) { + expect(projectContext.paths.value.projectRoot).toBe(packageRoot); + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.live("lets ambient env override discovered project env", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), - ); - yield* Effect.tryPromise(() => - writeFile( - join(projectRoot, "supabase", ".env"), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), "SUPABASE_API_URL=https://from-dotenv.example\nSUPABASE_ACCESS_TOKEN=sbp_dotenv\n", - ), - ); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", ".env.local"), "SUPABASE_ACCESS_TOKEN=sbp_local\n"), - ); + ); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env.local"), + "SUPABASE_ACCESS_TOKEN=sbp_local\n", + ); - const cliConfig = yield* Effect.gen(function* () { - return yield* CliConfig; - }).pipe( - Effect.provide( - buildLayer({ - cwd: projectRoot, - env: { - SUPABASE_API_URL: "https://from-ambient.example", - SUPABASE_ACCESS_TOKEN: "sbp_ambient", - }, - }), - ), - ); + const cliConfig = yield* CliConfig.pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { + SUPABASE_API_URL: "https://from-ambient.example", + SUPABASE_ACCESS_TOKEN: "sbp_ambient", + }, + }), + ), + ); - expect(cliConfig.apiUrl).toBe("https://from-ambient.example"); - expect(Option.isSome(cliConfig.accessToken)).toBe(true); - if (Option.isSome(cliConfig.accessToken)) { - expect(Redacted.value(cliConfig.accessToken.value)).toBe("sbp_ambient"); - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(cliConfig.apiUrl).toBe("https://from-ambient.example"); + expect(Option.isSome(cliConfig.accessToken)).toBe(true); + if (Option.isSome(cliConfig.accessToken)) { + expect(Redacted.value(cliConfig.accessToken.value)).toBe("sbp_ambient"); + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("has no PostHog key when nothing is injected or overridden", () => { - const tempDir = makeTempDir(); return Effect.gen(function* () { - const cliConfig = yield* CliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const cliConfig = yield* CliConfig.pipe( + Effect.provide(buildLayer({ cwd: tempDir, homeDir: path.join(tempDir, ".home") })), + ); - expect(Option.isNone(cliConfig.telemetryPosthogKey)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(Option.isNone(cliConfig.telemetryPosthogKey)).toBe(true); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => { - const tempDir = makeTempDir(); return Effect.gen(function* () { - const cliConfig = yield* CliConfig; - - expect(cliConfig.telemetryPosthogKey).toEqual(Option.some("phc_env_override")); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_env_override", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const cliConfig = yield* CliConfig.pipe( + Effect.provide( + buildLayer({ + cwd: tempDir, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_env_override" }, + }), + ), + ); + + expect(cliConfig.telemetryPosthogKey).toEqual(Option.some("phc_env_override")); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("uses SUPABASE_HOME (trimmed) when configured", () => { - const tempDir = makeTempDir(); - const supabaseHome = join(tempDir, "custom-supabase-home"); return Effect.gen(function* () { - const cliConfig = yield* CliConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const supabaseHome = path.join(tempDir, "custom-supabase-home"); + const cliConfig = yield* CliConfig.pipe( + Effect.provide( + buildLayer({ + cwd: tempDir, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: ` ${supabaseHome} ` }, + }), + ), + ); - expect(cliConfig.supabaseHome).toBe(supabaseHome); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir, env: { SUPABASE_HOME: ` ${supabaseHome} ` } })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(cliConfig.supabaseHome).toBe(supabaseHome); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); for (const value of ["", " "]) { it.live( `falls back to <homeDir>/.supabase when SUPABASE_HOME is ${JSON.stringify(value)}`, () => { - const tempDir = makeTempDir(); - const homeDir = join(tempDir, "home"); return Effect.gen(function* () { - const cliConfig = yield* CliConfig; - - expect(cliConfig.supabaseHome).toBe(join(homeDir, ".supabase")); - }).pipe( - Effect.provide(buildLayer({ cwd: tempDir, homeDir, env: { SUPABASE_HOME: value } })), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const homeDir = path.join(tempDir, "home"); + const cliConfig = yield* CliConfig.pipe( + Effect.provide(buildLayer({ cwd: tempDir, homeDir, env: { SUPABASE_HOME: value } })), + ); + + expect(cliConfig.supabaseHome).toBe(path.join(homeDir, ".supabase")); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }, ); } it.live("uses the build-injected PostHog key and host when no runtime override is set", () => { - const tempDir = makeTempDir(); return Effect.gen(function* () { - const cliConfig = yield* CliConfig; - - expect(cliConfig.telemetryPosthogHost).toBe("https://build-posthog.example"); - expect(cliConfig.telemetryPosthogKey).toEqual(Option.some("phc_build_key")); - }).pipe( - Effect.provide( - buildLayer({ - cwd: tempDir, - env: { - SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", - SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", - }, - }), - ), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-cli-config-" }); + yield* Effect.gen(function* () { + const cliConfig = yield* CliConfig.pipe( + Effect.provide( + buildLayer({ + cwd: tempDir, + homeDir: path.join(tempDir, ".home"), + env: { + SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", + SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", + }, + }), + ), + ); + + expect(cliConfig.telemetryPosthogHost).toBe("https://build-posthog.example"); + expect(cliConfig.telemetryPosthogKey).toEqual(Option.some("phc_build_key")); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/config/project-context.layer.ts b/apps/cli/src/next/config/project-context.layer.ts index 402c77fe65..46ab38b77b 100644 --- a/apps/cli/src/next/config/project-context.layer.ts +++ b/apps/cli/src/next/config/project-context.layer.ts @@ -1,5 +1,6 @@ import { loadProjectEnvironment } from "@supabase/config"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; +import { collectConfigEnvironment } from "../../shared/runtime/config-environment.ts"; import { RuntimeInfo } from "../../shared/runtime/runtime-info.service.ts"; import { ProjectContext } from "./project-context.service.ts"; @@ -10,9 +11,11 @@ const emptyProjectContext = ProjectContext.of({ const makeProjectContext = Effect.gen(function* () { const runtimeInfo = yield* RuntimeInfo; + const provider = yield* ConfigProvider.ConfigProvider; + const baseEnv = yield* collectConfigEnvironment(provider); const projectEnv = yield* loadProjectEnvironment({ cwd: runtimeInfo.cwd, - baseEnv: process.env, + baseEnv, }); if (projectEnv === null) { diff --git a/apps/cli/src/next/config/project-context.layer.unit.test.ts b/apps/cli/src/next/config/project-context.layer.unit.test.ts index b3eb8a324f..1ee05016d3 100644 --- a/apps/cli/src/next/config/project-context.layer.unit.test.ts +++ b/apps/cli/src/next/config/project-context.layer.unit.test.ts @@ -1,22 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { ProjectContext } from "./project-context.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-context-")); -} - -function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { +function buildLayer(opts: { cwd: string; homeDir: string; env?: Record<string, string> }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: join(opts.cwd, ".home"), + homeDir: opts.homeDir, }); const envLayer = processEnvLayer(opts.env ?? {}); return projectContextLayer.pipe( @@ -28,14 +20,15 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string> }) { describe("projectContextLayer", () => { it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile( - join(projectRoot, "supabase", "config.toml"), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-context-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), [ 'project_id = "with-env-ports"', "", @@ -49,46 +42,83 @@ describe("projectContextLayer", () => { 'port = "env(SUPABASE_ANALYTICS_PORT)"', "", ].join("\n"), - ), - ); + ); - const projectContext = yield* Effect.gen(function* () { - return yield* ProjectContext; - }).pipe( - Effect.provide( - buildLayer({ - cwd: projectRoot, - env: { - SUPABASE_API_PORT: "54321", - SUPABASE_DB_PORT: "54322", - SUPABASE_ANALYTICS_PORT: "54327", - }, - }), - ), - ); + const projectContext = yield* ProjectContext.pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { + SUPABASE_API_PORT: "54321", + SUPABASE_DB_PORT: "54322", + SUPABASE_ANALYTICS_PORT: "54327", + }, + }), + ), + ); - expect(Option.isSome(projectContext.paths)).toBe(true); - if (Option.isSome(projectContext.paths)) { - expect(projectContext.paths.value.projectRoot).toBe(projectRoot); - } - expect(Option.isSome(projectContext.projectEnv)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(Option.isSome(projectContext.paths)).toBe(true); + if (Option.isSome(projectContext.paths)) { + expect(projectContext.paths.value.projectRoot).toBe(projectRoot); + } + expect(Option.isSome(projectContext.projectEnv)).toBe(true); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); - it.live("returns empty context when no supabase project is found", () => { - const tempDir = makeTempDir(); + it.live("preserves array-shaped environment variables from the provider", () => { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-context-array-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "array-env"\n', + ); + const projectContext = yield* ProjectContext.pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { + SUPABASE_API_CORS_ORIGINS_0: "https://one.example", + SUPABASE_API_CORS_ORIGINS_1: "https://two.example", + }, + }), + ), + ); + + expect(Option.isSome(projectContext.projectEnv)).toBe(true); + if (Option.isSome(projectContext.projectEnv)) { + expect(projectContext.projectEnv.value.values.SUPABASE_API_CORS_ORIGINS_0).toBe( + "https://one.example", + ); + expect(projectContext.projectEnv.value.values.SUPABASE_API_CORS_ORIGINS_1).toBe( + "https://two.example", + ); + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.live("returns empty context when no supabase project is found", () => { return Effect.gen(function* () { - const projectContext = yield* Effect.gen(function* () { - return yield* ProjectContext; - }).pipe(Effect.provide(buildLayer({ cwd: tempDir }))); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-context-" }); + yield* Effect.gen(function* () { + const projectContext = yield* ProjectContext.pipe( + Effect.provide(buildLayer({ cwd: tempDir, homeDir: path.join(tempDir, ".home") })), + ); - expect(Option.isNone(projectContext.paths)).toBe(true); - expect(Option.isNone(projectContext.projectEnv)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + expect(Option.isNone(projectContext.paths)).toBe(true); + expect(Option.isNone(projectContext.projectEnv)).toBe(true); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/config/project-gitignore.ts b/apps/cli/src/next/config/project-gitignore.ts index 38a6045857..a82d6a5806 100644 --- a/apps/cli/src/next/config/project-gitignore.ts +++ b/apps/cli/src/next/config/project-gitignore.ts @@ -11,7 +11,7 @@ export const ensureProjectStateIgnored = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const gitRoot = yield* Effect.tryPromise(() => findGitRootPath(projectRoot)).pipe(Effect.orDie); + const gitRoot = yield* findGitRootPath(projectRoot).pipe(Effect.orDie); if (gitRoot === undefined) { return; diff --git a/apps/cli/src/next/config/project-home.layer.unit.test.ts b/apps/cli/src/next/config/project-home.layer.unit.test.ts index 98a8fd34fd..9daf0371d3 100644 --- a/apps/cli/src/next/config/project-home.layer.unit.test.ts +++ b/apps/cli/src/next/config/project-home.layer.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option, Result } from "effect"; +import { Cause, Effect, FileSystem, Exit, Layer, Option, Path, Result } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; @@ -12,14 +8,10 @@ import { projectHomeLayer } from "./project-home.layer.ts"; import { ProjectContext } from "./project-context.service.ts"; import { ProjectHome, ProjectHomeNotDirectoryError } from "./project-home.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-home-")); -} - -function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: string }) { +function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir: string }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir, }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredProjectContextLayer = projectContextLayer.pipe( @@ -30,6 +22,7 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: const discoveredCliConfigLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(discoveredProjectContextLayer), + Layer.provide(envLayer), ); const discoveredProjectHomeLayer = projectHomeLayer.pipe( Layer.provide(BunServices.layer), @@ -39,9 +32,6 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: ); return Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - envLayer, discoveredProjectContextLayer, discoveredCliConfigLayer, discoveredProjectHomeLayer, @@ -50,167 +40,153 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: describe("projectHomeLayer", () => { it.live("resolves a repo-local project home from the nearest discovered config root", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const cwd = join(packageRoot, "src"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(packageRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'), - ); - - const { projectHome, projectContext } = yield* Effect.gen(function* () { - return { - projectHome: yield* ProjectHome, - projectContext: yield* ProjectContext, - }; - }).pipe(Effect.provide(buildLayer({ cwd, env: { SUPABASE_HOME: supabaseHome } }))); - - expect(Option.isSome(projectContext.paths)).toBe(true); - expect(projectHome.projectRoot).toBe(packageRoot); - expect(projectHome.supabaseDir).toBe(join(packageRoot, "supabase")); - expect(projectHome.projectHomeDir).toBe(join(packageRoot, ".supabase")); - expect(projectHome.projectLocalVersionsPath).toBe( - join(packageRoot, ".supabase", "local-versions.json"), - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-home-" }); + yield* Effect.gen(function* () { + const repoRoot = path.join(tempDir, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(packageRoot, "src"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', + ); + + const projectHome = yield* ProjectHome.pipe( + Effect.provide( + buildLayer({ + cwd, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }), + ), + ); + const projectContext = yield* ProjectContext.pipe( + Effect.provide( + buildLayer({ + cwd, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }), + ), + ); + + expect(Option.isSome(projectContext.paths)).toBe(true); + expect(projectHome.projectRoot).toBe(packageRoot); + expect(projectHome.supabaseDir).toBe(path.join(packageRoot, "supabase")); + expect(projectHome.projectHomeDir).toBe(path.join(packageRoot, ".supabase")); + expect(projectHome.projectLocalVersionsPath).toBe( + path.join(packageRoot, ".supabase", "local-versions.json"), + ); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("falls back to the nearest linked project root when no project config exists", () => { - const tempDir = makeTempDir(); - const repoRoot = join(tempDir, "repo"); - const projectRoot = join(repoRoot, "apps", "web"); - const cwd = join(projectRoot, "src", "feature"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, ".supabase", "project.json"), "{}\n"), - ); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - - const layer = buildLayer({ cwd, env: { SUPABASE_HOME: join(tempDir, "supabase-home") } }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); - - expect(projectHome.projectRoot).toBe(projectRoot); - expect(projectHome.projectHomeDir).toBe(join(projectRoot, ".supabase")); - expect(projectHome.supabaseDir).toBe(join(projectRoot, "supabase")); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-home-" }); + yield* Effect.gen(function* () { + const repoRoot = path.join(tempDir, "repo"); + const projectRoot = path.join(repoRoot, "apps", "web"); + const cwd = path.join(projectRoot, "src", "feature"); + yield* fs.makeDirectory(path.join(projectRoot, ".supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, ".supabase", "project.json"), "{}\n"); + yield* fs.makeDirectory(cwd, { recursive: true }); + + const projectHome = yield* ProjectHome.pipe( + Effect.provide( + buildLayer({ + cwd, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }), + ), + ); + + expect(projectHome.projectRoot).toBe(projectRoot); + expect(projectHome.projectHomeDir).toBe(path.join(projectRoot, ".supabase")); + expect(projectHome.supabaseDir).toBe(path.join(projectRoot, "supabase")); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("does not let a bare ancestor .supabase directory capture a nested checkout", () => { - const tempDir = makeTempDir(); - const parentRoot = join(tempDir, "workspace"); - const cwd = join(parentRoot, "test-cli-v3"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(parentRoot, ".supabase"), { recursive: true })); - yield* Effect.tryPromise(() => mkdir(cwd, { recursive: true })); - - const layer = buildLayer({ cwd, env: { SUPABASE_HOME: join(tempDir, "supabase-home") } }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); - - expect(projectHome.projectRoot).toBe(cwd); - expect(projectHome.projectHomeDir).toBe(join(cwd, ".supabase")); - expect(projectHome.projectLinkPath).toBe(join(cwd, ".supabase", "project.json")); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-home-" }); + yield* Effect.gen(function* () { + const parentRoot = path.join(tempDir, "workspace"); + const cwd = path.join(parentRoot, "test-cli-v3"); + yield* fs.makeDirectory(path.join(parentRoot, ".supabase"), { recursive: true }); + yield* fs.makeDirectory(cwd, { recursive: true }); + + const projectHome = yield* ProjectHome.pipe( + Effect.provide( + buildLayer({ + cwd, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }), + ), + ); + + expect(projectHome.projectRoot).toBe(cwd); + expect(projectHome.projectHomeDir).toBe(path.join(cwd, ".supabase")); + expect(projectHome.projectLinkPath).toBe(path.join(cwd, ".supabase", "project.json")); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("creates the repo-local .supabase directory lazily", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - return Effect.gen(function* () { - const layer = buildLayer({ - cwd: projectRoot, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, - }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); - - yield* projectHome.ensureProjectHomeDir; - yield* Effect.tryPromise(() => writeFile(projectHome.projectLinkPath, "{}\n")); - expect(yield* Effect.tryPromise(() => readFile(projectHome.projectLinkPath, "utf8"))).toBe( - "{}\n", - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-home-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const projectHome = yield* ProjectHome.pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }), + ), + ); + + yield* projectHome.ensureProjectHomeDir; + yield* fs.writeFileString(projectHome.projectLinkPath, "{}\n"); + expect(yield* fs.readFileString(projectHome.projectLinkPath)).toBe("{}\n"); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("dies with ProjectHomeNotDirectoryError when a FILE occupies the .supabase path", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(projectRoot, { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, ".supabase"), "not a directory\n"), - ); - - const layer = buildLayer({ - cwd: projectRoot, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, - }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); - - const exit = yield* projectHome.ensureProjectHomeDir.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const defect = Cause.findDefect(exit.cause); - expect(Result.isSuccess(defect)).toBe(true); - if (Result.isSuccess(defect)) { - expect(defect.success).toBeInstanceOf(ProjectHomeNotDirectoryError); - expect(defect.success).toMatchObject({ _tag: "ProjectHomeNotDirectoryError" }); - expect((defect.success as ProjectHomeNotDirectoryError).message).toContain( - "could not be created", - ); - } - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); - }); - - it.live( - "dies with ProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", - () => { - // Distinct from the AlreadyExists case above: here `.supabase` itself - // doesn't exist, but a FILE sits on one of ITS OWN parent directories - // (`<tempDir>/proj`), so `mkdir(..., { recursive: true })` fails with - // ENOTDIR (-> PlatformError reason "BadResource") while trying to - // traverse through it, rather than EEXIST on the leaf itself. - const tempDir = makeTempDir(); - const fileAsDir = join(tempDir, "proj"); - const cwd = join(fileAsDir, "child"); - - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeFile(fileAsDir, "not a directory\n")); - - const layer = buildLayer({ - cwd, - env: { SUPABASE_HOME: join(tempDir, "supabase-home") }, - }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-home-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(projectRoot, { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, ".supabase"), "not a directory\n"); + + const projectHome = yield* ProjectHome.pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }), + ), + ); const exit = yield* projectHome.ensureProjectHomeDir.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); @@ -225,9 +201,52 @@ describe("projectHomeLayer", () => { ); } } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); + }); + + it.live( + "dies with ProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", + () => { + // Distinct from the AlreadyExists case above: here `.supabase` itself + // doesn't exist, but a FILE sits on one of ITS OWN parent directories + // (`<tempDir>/proj`), so `mkdir(..., { recursive: true })` fails with + // ENOTDIR (-> PlatformError reason "BadResource") while trying to + // traverse through it, rather than EEXIST on the leaf itself. + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-home-" }); + yield* Effect.gen(function* () { + const fileAsDir = path.join(tempDir, "proj"); + const cwd = path.join(fileAsDir, "child"); + yield* fs.writeFileString(fileAsDir, "not a directory\n"); + + const projectHome = yield* ProjectHome.pipe( + Effect.provide( + buildLayer({ + cwd, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }), + ), + ); + + const exit = yield* projectHome.ensureProjectHomeDir.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const defect = Cause.findDefect(exit.cause); + expect(Result.isSuccess(defect)).toBe(true); + if (Result.isSuccess(defect)) { + expect(defect.success).toBeInstanceOf(ProjectHomeNotDirectoryError); + expect(defect.success).toMatchObject({ _tag: "ProjectHomeNotDirectoryError" }); + expect((defect.success as ProjectHomeNotDirectoryError).message).toContain( + "could not be created", + ); + } + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }, ); }); diff --git a/apps/cli/src/next/config/project-link-refresh.ts b/apps/cli/src/next/config/project-link-refresh.ts index 5a74128af8..9e99a6b1c0 100644 --- a/apps/cli/src/next/config/project-link-refresh.ts +++ b/apps/cli/src/next/config/project-link-refresh.ts @@ -4,7 +4,8 @@ import { fillServiceVersionManifest, normalizeServiceVersions, } from "@supabase/stack/effect"; -import { Effect } from "effect"; +import { DateTime, Effect } from "effect"; +import { Clock } from "effect"; import { ProjectLinkRemote } from "./project-link-remote.service.ts"; import { ProjectLinkState } from "./project-link-state.service.ts"; @@ -53,7 +54,7 @@ export const refreshLinkedProjectSnapshot = Effect.fnUntraced(function* ( name: "main", is_default: true, }, - fetchedAt: new Date().toISOString(), + fetchedAt: DateTime.formatIso(DateTime.makeUnsafe(yield* Clock.currentTimeMillis)), versions: linkedProject.versions, }); diff --git a/apps/cli/src/next/config/project-link-remote.layer.ts b/apps/cli/src/next/config/project-link-remote.layer.ts index a054fa984a..c13e5f1042 100644 --- a/apps/cli/src/next/config/project-link-remote.layer.ts +++ b/apps/cli/src/next/config/project-link-remote.layer.ts @@ -9,6 +9,7 @@ import { PlatformApi } from "../auth/platform-api.service.ts"; import { CliConfig } from "./cli-config.service.ts"; import { ProjectLinkRemote, + NoProjectApiKeyError, type AccessibleProject, type LinkedProjectSnapshot, type LinkedProjectVersionService, @@ -23,16 +24,6 @@ export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersio } } -export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ - readonly projectRef: string; -}> { - get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A successful api-keys response with no usable key — an API response - // problem, not a raw status failure. - return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; - } -} - type ProjectApiKey = { readonly name: string; readonly type?: "legacy" | "publishable" | "secret" | null; @@ -126,7 +117,7 @@ const fetchPostgrestVersion = Effect.fnUntraced(function* ( const normalized = version?.trim().split(/\s+/)[0]; if (normalized === undefined || normalized.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "postgrest" })); + return yield* new ServiceVersionNotFoundError({ service: "postgrest" }); } return normalized.startsWith("v") ? normalized : `v${normalized}`; }); @@ -146,7 +137,7 @@ const fetchAuthVersion = Effect.fnUntraced(function* ( : undefined; if (version === undefined || version.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "auth" })); + return yield* new ServiceVersionNotFoundError({ service: "auth" }); } return version; }); @@ -158,14 +149,14 @@ const fetchStorageVersion = Effect.fnUntraced(function* ( ) { const version = (yield* fetchText(client, `${baseUrl}/storage/v1/version`, accessKey)).trim(); if (version.length === 0 || version === "0.0.0") { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "storage" })); + return yield* new ServiceVersionNotFoundError({ service: "storage" }); } return version.startsWith("v") ? version : `v${version}`; }); -const fetchOptionalVersion = <Service extends Exclude<LinkedProjectVersionService, "postgres">>( +const fetchOptionalVersion = <Service extends Exclude<LinkedProjectVersionService, "postgres">, E>( service: Service, - effect: Effect.Effect<string, unknown>, + effect: Effect.Effect<string, E>, ) => effect.pipe( Effect.exit, @@ -204,7 +195,7 @@ const makeProjectLinkRemote = Effect.gen(function* () { const accessKey = selectTenantAccessKey(apiKeys); if (accessKey === undefined) { - return yield* Effect.fail(new NoProjectApiKeyError({ projectRef })); + return yield* new NoProjectApiKeyError({ projectRef }); } const baseUrl = tenantBaseUrl(project.ref, cliConfig.projectHost); diff --git a/apps/cli/src/next/config/project-link-remote.service.ts b/apps/cli/src/next/config/project-link-remote.service.ts index 36e10c75f5..0f60cba88e 100644 --- a/apps/cli/src/next/config/project-link-remote.service.ts +++ b/apps/cli/src/next/config/project-link-remote.service.ts @@ -1,5 +1,11 @@ +import type { SupabaseApiError } from "@supabase/api/effect"; +import { Context, Data } from "effect"; import type { Effect } from "effect"; -import { Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LinkedServiceVersions } from "./project-link-state.service.ts"; export const linkedProjectVersionServices = ["postgres", "postgrest", "auth", "storage"] as const; @@ -24,11 +30,24 @@ export interface LinkedProjectSnapshot extends AccessibleProject { readonly unavailableServices: ReadonlyArray<LinkedProjectVersionService>; } +export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ + readonly projectRef: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } +} + +export type ProjectLinkRemoteError = SupabaseApiError | NoProjectApiKeyError; + interface ProjectLinkRemoteShape { - readonly listAccessibleProjects: Effect.Effect<ReadonlyArray<AccessibleProject>, unknown>; + readonly listAccessibleProjects: Effect.Effect< + ReadonlyArray<AccessibleProject>, + SupabaseApiError + >; readonly fetchLinkedProject: ( projectRef: string, - ) => Effect.Effect<LinkedProjectSnapshot, unknown>; + ) => Effect.Effect<LinkedProjectSnapshot, ProjectLinkRemoteError>; } export class ProjectLinkRemote extends Context.Service<ProjectLinkRemote, ProjectLinkRemoteShape>()( diff --git a/apps/cli/src/next/config/project-link-state.layer.ts b/apps/cli/src/next/config/project-link-state.layer.ts index 8bc57eac68..8ced01d5ac 100644 --- a/apps/cli/src/next/config/project-link-state.layer.ts +++ b/apps/cli/src/next/config/project-link-state.layer.ts @@ -30,25 +30,17 @@ const makeProjectLinkState = Effect.gen(function* () { const loadFromPath = (filePath: string) => Effect.gen(function* () { - const exists = yield* fs - .exists(filePath) - .pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); + const exists = yield* fs.exists(filePath); if (!exists) { return Option.none<ProjectLinkStateValue>(); } - const content = yield* fs - .readFileString(filePath) - .pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); - const decoded = yield* decodeProjectLinkStateValue(content).pipe( - Effect.mapError(() => invalidProjectLinkStateError(filePath)), - ); + const content = yield* fs.readFileString(filePath); + const decoded = yield* decodeProjectLinkStateValue(content); return Option.some(decoded); - }); + }).pipe(Effect.mapError(() => invalidProjectLinkStateError(filePath))); - const load = Effect.gen(function* () { - return yield* loadFromPath(projectHome.projectLinkPath); - }); + const load = loadFromPath(projectHome.projectLinkPath); const save = (state: ProjectLinkStateValue) => Effect.gen(function* () { @@ -67,12 +59,10 @@ const makeProjectLinkState = Effect.gen(function* () { Effect.gen(function* () { const current = yield* load; if (Option.isNone(current)) { - return yield* Effect.fail( - new ProjectNotLinkedError({ - detail: "Cannot set active branch: no linked project found.", - suggestion: "Run `supabase link` to link this checkout to a Supabase project first.", - }), - ); + return yield* new ProjectNotLinkedError({ + detail: "Cannot set active branch: no linked project found.", + suggestion: "Run `supabase link` to link this checkout to a Supabase project first.", + }); } yield* save({ ...current.value, active_branch: branch }); }); diff --git a/apps/cli/src/next/config/project-link-state.layer.unit.test.ts b/apps/cli/src/next/config/project-link-state.layer.unit.test.ts index de2bc88943..bd88347d9d 100644 --- a/apps/cli/src/next/config/project-link-state.layer.unit.test.ts +++ b/apps/cli/src/next/config/project-link-state.layer.unit.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; @@ -14,17 +10,14 @@ import { projectLinkStateLayer } from "./project-link-state.layer.ts"; import { InvalidProjectLinkStateError, ProjectLinkState, + ProjectLinkStateValueSchema, ProjectNotLinkedError, } from "./project-link-state.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-link-state-")); -} - -function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: string }) { +function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir: string }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir, }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredProjectContextLayer = projectContextLayer.pipe( @@ -35,6 +28,7 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: const discoveredCliConfigLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(discoveredProjectContextLayer), + Layer.provideMerge(BunServices.layer), ); const discoveredProjectHomeLayer = projectHomeLayer.pipe( Layer.provide(BunServices.layer), @@ -81,225 +75,241 @@ const SAMPLE_STATE = { describe("projectLinkStateLayer", () => { it.live("saves and loads repo-local project link state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), - ); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); - - yield* linkState.save(SAMPLE_STATE); - const loaded = yield* linkState.load; - - expect(Option.isSome(loaded)).toBe(true); - if (Option.isSome(loaded)) { - expect(loaded.value).toEqual(SAMPLE_STATE); - } - - const rawFile = yield* Effect.tryPromise(() => readFile(projectHome.projectLinkPath, "utf8")); - expect(rawFile).toContain('"project":'); - expect(rawFile).toContain('"active_branch":'); - const raw = JSON.parse(rawFile) as typeof SAMPLE_STATE; - expect(raw).toEqual(SAMPLE_STATE); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const projectHome = yield* ProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + yield* linkState.save(SAMPLE_STATE); + const loaded = yield* linkState.load; + + expect(Option.isSome(loaded)).toBe(true); + if (Option.isSome(loaded)) { + expect(loaded.value).toEqual(SAMPLE_STATE); + } + + const rawFile = yield* fs.readFileString(projectHome.projectLinkPath); + expect(rawFile).toContain('"project":'); + expect(rawFile).toContain('"active_branch":'); + const raw = yield* Schema.decodeEffect(Schema.fromJsonString(ProjectLinkStateValueSchema))( + rawFile, + ); + expect(raw).toEqual(SAMPLE_STATE); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("clears repo-local link state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), - ); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(layer)); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); - - yield* linkState.save(SAMPLE_STATE); - yield* linkState.clear; - - const loaded = yield* linkState.load; - expect(Option.isNone(loaded)).toBe(true); - yield* Effect.tryPromise(() => readFile(projectHome.projectLinkPath, "utf8")).pipe( - Effect.flip, - Effect.asVoid, - ); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const projectHome = yield* ProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + yield* linkState.save(SAMPLE_STATE); + yield* linkState.clear; + + const loaded = yield* linkState.load; + expect(Option.isNone(loaded)).toBe(true); + yield* fs.readFileString(projectHome.projectLinkPath).pipe(Effect.flip, Effect.asVoid); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("fails with a tagged error when repo-local link state is malformed", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".supabase"), { recursive: true })); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const { projectHome, linkState } = yield* Effect.gen(function* () { - return { - projectHome: yield* ProjectHome, - linkState: yield* ProjectLinkState, - }; - }).pipe(Effect.provide(layer)); - - yield* Effect.tryPromise(() => writeFile(projectHome.projectLinkPath, "{not-json")); - - const exit = yield* linkState.load.pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(InvalidProjectLinkStateError); - expect(error.value).toMatchObject({ - _tag: "InvalidProjectLinkStateError", - suggestion: "Fix or remove project.json, then retry the command.", - }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, ".supabase"), { recursive: true }); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const projectHome = yield* ProjectHome.pipe(Effect.provide(layer)); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + yield* fs.writeFileString(projectHome.projectLinkPath, "{not-json"); + + const exit = yield* linkState.load.pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(InvalidProjectLinkStateError); + expect(error.value).toMatchObject({ + _tag: "InvalidProjectLinkStateError", + suggestion: "Fix or remove project.json, then retry the command.", + }); + } } - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("getActiveBranch returns none when not linked", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); - - const activeBranch = yield* linkState.getActiveBranch; - expect(Option.isNone(activeBranch)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + const activeBranch = yield* linkState.getActiveBranch; + expect(Option.isNone(activeBranch)).toBe(true); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("getActiveBranch returns the persisted active_branch", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); - - yield* linkState.save(SAMPLE_STATE); - - const activeBranch = yield* linkState.getActiveBranch; - expect(Option.isSome(activeBranch)).toBe(true); - if (Option.isSome(activeBranch)) { - expect(activeBranch.value).toEqual({ - ref: "abcdefghijklmnopqrst", - name: "main", - is_default: true, + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, }); - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + yield* linkState.save(SAMPLE_STATE); + + const activeBranch = yield* linkState.getActiveBranch; + expect(Option.isSome(activeBranch)).toBe(true); + if (Option.isSome(activeBranch)) { + expect(activeBranch.value).toEqual({ + ref: "abcdefghijklmnopqrst", + name: "main", + is_default: true, + }); + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live( "setActiveBranch updates only active_branch, leaving project and versions unchanged", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); - - yield* linkState.save(SAMPLE_STATE); - - const newBranch = { ref: "branchrefabcdefghijk", name: "feature-x", is_default: false }; - yield* linkState.setActiveBranch(newBranch); - - const loaded = yield* linkState.load; - expect(Option.isSome(loaded)).toBe(true); - if (Option.isSome(loaded)) { - expect(loaded.value.active_branch).toEqual(newBranch); - expect(loaded.value.project).toEqual(SAMPLE_STATE.project); - expect(loaded.value.versions).toEqual(SAMPLE_STATE.versions); - expect(loaded.value.fetchedAt).toBe(SAMPLE_STATE.fetchedAt); - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + yield* linkState.save(SAMPLE_STATE); + + const newBranch = { ref: "branchrefabcdefghijk", name: "feature-x", is_default: false }; + yield* linkState.setActiveBranch(newBranch); + + const loaded = yield* linkState.load; + expect(Option.isSome(loaded)).toBe(true); + if (Option.isSome(loaded)) { + expect(loaded.value.active_branch).toEqual(newBranch); + expect(loaded.value.project).toEqual(SAMPLE_STATE.project); + expect(loaded.value.versions).toEqual(SAMPLE_STATE.versions); + expect(loaded.value.fetchedAt).toBe(SAMPLE_STATE.fetchedAt); + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }, ); it.live("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, ".git"), { recursive: true })); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const linkState = yield* Effect.gen(function* () { - return yield* ProjectLinkState; - }).pipe(Effect.provide(layer)); - - const exit = yield* linkState - .setActiveBranch({ ref: "branchrefabcdefghijk", name: "feature-x", is_default: false }) - .pipe(Effect.exit); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const error = Cause.findErrorOption(exit.cause); - expect(Option.isSome(error)).toBe(true); - if (Option.isSome(error)) { - expect(error.value).toBeInstanceOf(ProjectNotLinkedError); - expect(error.value).toMatchObject({ - _tag: "ProjectNotLinkedError", - suggestion: "Run `supabase link` to link this checkout to a Supabase project first.", - }); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-link-state-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, ".git"), { recursive: true }); + + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const linkState = yield* ProjectLinkState.pipe(Effect.provide(layer)); + + const exit = yield* linkState + .setActiveBranch({ ref: "branchrefabcdefghijk", name: "feature-x", is_default: false }) + .pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const error = Cause.findErrorOption(exit.cause); + expect(Option.isSome(error)).toBe(true); + if (Option.isSome(error)) { + expect(error.value).toBeInstanceOf(ProjectNotLinkedError); + expect(error.value).toMatchObject({ + _tag: "ProjectNotLinkedError", + suggestion: "Run `supabase link` to link this checkout to a Supabase project first.", + }); + } } - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/config/project-local-service-versions.layer.ts b/apps/cli/src/next/config/project-local-service-versions.layer.ts index 86389b9ca5..3980ba2a74 100644 --- a/apps/cli/src/next/config/project-local-service-versions.layer.ts +++ b/apps/cli/src/next/config/project-local-service-versions.layer.ts @@ -38,9 +38,7 @@ const makeProjectLocalServiceVersions = Effect.gen(function* () { return Option.some(decoded); }); - const load = Effect.gen(function* () { - return yield* loadFromPath(projectHome.projectLocalVersionsPath); - }); + const load = loadFromPath(projectHome.projectLocalVersionsPath); return ProjectLocalServiceVersions.of({ load, diff --git a/apps/cli/src/next/config/project-local-service-versions.layer.unit.test.ts b/apps/cli/src/next/config/project-local-service-versions.layer.unit.test.ts index d3704230d3..c39d97c6a1 100644 --- a/apps/cli/src/next/config/project-local-service-versions.layer.unit.test.ts +++ b/apps/cli/src/next/config/project-local-service-versions.layer.unit.test.ts @@ -1,26 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { mockRuntimeInfo, processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { projectHomeLayer } from "./project-home.layer.ts"; import { projectLocalServiceVersionsLayer } from "./project-local-service-versions.layer.ts"; import { ProjectHome } from "./project-home.service.ts"; -import { ProjectLocalServiceVersions } from "./project-local-service-versions.service.ts"; +import { + LocalServiceVersionsStateSchema, + ProjectLocalServiceVersions, +} from "./project-local-service-versions.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-local-versions-")); -} - -function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: string }) { +function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir: string }) { const runtimeInfoLayer = mockRuntimeInfo({ cwd: opts.cwd, - homeDir: opts.homeDir ?? join(opts.cwd, ".home"), + homeDir: opts.homeDir, }); const envLayer = processEnvLayer(opts.env ?? {}); const discoveredProjectContextLayer = projectContextLayer.pipe( @@ -31,6 +26,7 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: const discoveredCliConfigLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(discoveredProjectContextLayer), + Layer.provide(envLayer), ); const discoveredProjectHomeLayer = projectHomeLayer.pipe( Layer.provide(BunServices.layer), @@ -44,9 +40,6 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: ); return Layer.mergeAll( - BunServices.layer, - runtimeInfoLayer, - envLayer, discoveredProjectContextLayer, discoveredCliConfigLayer, discoveredProjectHomeLayer, @@ -56,71 +49,71 @@ function buildLayer(opts: { cwd: string; env?: Record<string, string>; homeDir?: describe("projectLocalServiceVersionsLayer", () => { it.live("loads local service version overrides from repo-local state", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); - - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const { projectHome, localVersions } = yield* Effect.gen(function* () { - return { - projectHome: yield* ProjectHome, - localVersions: yield* ProjectLocalServiceVersions, - }; - }).pipe(Effect.provide(layer)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-local-versions-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + const supabaseHome = path.join(tempDir, "supabase-home"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); - yield* projectHome.ensureProjectHomeDir; - yield* Effect.tryPromise(() => - writeFile( - projectHome.projectLocalVersionsPath, - JSON.stringify( - { - updatedAt: "2026-03-21T12:00:00.000Z", - versions: { - auth: "v2.180.0", - storage: "1.40.0", - }, - }, - null, - 2, - ), - ), - ); + const layer = buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: supabaseHome }, + }); + const projectHome = yield* ProjectHome.pipe(Effect.provide(layer)); + const localVersions = yield* ProjectLocalServiceVersions.pipe(Effect.provide(layer)); - const loaded = yield* localVersions.load; - expect(Option.isSome(loaded)).toBe(true); - if (Option.isSome(loaded)) { - expect(loaded.value.versions).toEqual({ - auth: "v2.180.0", - storage: "1.40.0", + yield* projectHome.ensureProjectHomeDir; + const contents = yield* Schema.encodeEffect( + Schema.fromJsonString(LocalServiceVersionsStateSchema), + )({ + updatedAt: "2026-03-21T12:00:00.000Z", + versions: { + auth: "v2.180.0", + storage: "1.40.0", + }, }); - } - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + yield* fs.writeFileString(projectHome.projectLocalVersionsPath, contents); + + const loaded = yield* localVersions.load; + expect(Option.isSome(loaded)).toBe(true); + if (Option.isSome(loaded)) { + expect(loaded.value.versions).toEqual({ + auth: "v2.180.0", + storage: "1.40.0", + }); + } + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); it.live("returns none when no local override file exists", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); - const supabaseHome = join(tempDir, "supabase-home"); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => writeFile(join(projectRoot, "supabase", "config.toml"), "")); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-local-versions-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.toml"), ""); - const layer = buildLayer({ cwd: projectRoot, env: { SUPABASE_HOME: supabaseHome } }); - const localVersions = yield* Effect.gen(function* () { - return yield* ProjectLocalServiceVersions; - }).pipe(Effect.provide(layer)); + const localVersions = yield* ProjectLocalServiceVersions.pipe( + Effect.provide( + buildLayer({ + cwd: projectRoot, + homeDir: path.join(tempDir, ".home"), + env: { SUPABASE_HOME: path.join(tempDir, "supabase-home") }, + }), + ), + ); - const loaded = yield* localVersions.load; - expect(Option.isNone(loaded)).toBe(true); - }).pipe( - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), - ); + const loaded = yield* localVersions.load; + expect(Option.isNone(loaded)).toBe(true); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); + }).pipe(Effect.provide(BunServices.layer)); }); }); diff --git a/apps/cli/src/next/config/project-runtime.layer.ts b/apps/cli/src/next/config/project-runtime.layer.ts index beefea3671..fc6c4e34dc 100644 --- a/apps/cli/src/next/config/project-runtime.layer.ts +++ b/apps/cli/src/next/config/project-runtime.layer.ts @@ -5,7 +5,7 @@ import { cliConfigLayer } from "./cli-config.layer.ts"; import { projectContextLayer } from "./project-context.layer.ts"; import { projectHomeLayer } from "./project-home.layer.ts"; -const discoveredProjectContextLayer = projectContextLayer.pipe( +export const discoveredProjectContextLayer = projectContextLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(BunServices.layer), ); diff --git a/apps/cli/src/next/config/project-runtime.layer.unit.test.ts b/apps/cli/src/next/config/project-runtime.layer.unit.test.ts index 00e1e91702..fd3423f61f 100644 --- a/apps/cli/src/next/config/project-runtime.layer.unit.test.ts +++ b/apps/cli/src/next/config/project-runtime.layer.unit.test.ts @@ -1,39 +1,36 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { projectCommandBaseLayer } from "./project-runtime.layer.ts"; import { ProjectHome } from "./project-home.service.ts"; -function makeTempDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-runtime-")); -} - describe("project-runtime.layer", () => { it.live("builds the shared project runtime for config-discovered checkouts", () => { - const tempDir = makeTempDir(); - const projectRoot = join(tempDir, "repo"); const previousCwd = process.cwd(); return Effect.gen(function* () { - yield* Effect.tryPromise(() => mkdir(join(projectRoot, "supabase"), { recursive: true })); - yield* Effect.tryPromise(() => - writeFile(join(projectRoot, "supabase", "config.toml"), 'project_id = "repo"\n'), - ); - yield* Effect.sync(() => process.chdir(projectRoot)); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-project-runtime-" }); + yield* Effect.gen(function* () { + const projectRoot = path.join(tempDir, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { recursive: true }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + yield* Effect.sync(() => process.chdir(projectRoot)); - const projectHome = yield* Effect.gen(function* () { - return yield* ProjectHome; - }).pipe(Effect.provide(Layer.mergeAll(BunServices.layer, projectCommandBaseLayer))); - const resolvedProjectRoot = yield* Effect.tryPromise(() => realpath(projectRoot)); - expect(projectHome.projectRoot).toBe(resolvedProjectRoot); - expect(projectHome.projectHomeDir).toBe(join(resolvedProjectRoot, ".supabase")); + const projectHome = yield* ProjectHome.pipe( + Effect.provide(Layer.mergeAll(BunServices.layer, projectCommandBaseLayer)), + ); + const resolvedProjectRoot = yield* fs.realPath(projectRoot); + expect(projectHome.projectRoot).toBe(resolvedProjectRoot); + expect(projectHome.projectHomeDir).toBe(path.join(resolvedProjectRoot, ".supabase")); + }).pipe(Effect.ensuring(fs.remove(tempDir, { recursive: true }).pipe(Effect.ignore))); }).pipe( Effect.ensuring(Effect.sync(() => process.chdir(previousCwd))), - Effect.ensuring(Effect.tryPromise(() => rm(tempDir, { recursive: true, force: true }))), + Effect.provide(BunServices.layer), ); }); }); diff --git a/apps/cli/src/next/config/service-version-resolution.ts b/apps/cli/src/next/config/service-version-resolution.ts index dccd3321c3..114ae4eb84 100644 --- a/apps/cli/src/next/config/service-version-resolution.ts +++ b/apps/cli/src/next/config/service-version-resolution.ts @@ -43,21 +43,17 @@ export const parseServiceVersionOverrides = Effect.fnUntraced(function* ( const rawVersion = separatorIndex === -1 ? "" : rawOverride.slice(separatorIndex + 1).trim(); if (!isServiceName(rawService)) { - return yield* Effect.fail( - new InvalidServiceVersionOverrideError({ - detail: `Invalid service version override '${rawOverride}'. Unknown service '${rawService}'.`, - suggestion: `Use one of: ${SERVICE_NAMES.join(", ")}.`, - }), - ); + return yield* new InvalidServiceVersionOverrideError({ + detail: `Invalid service version override '${rawOverride}'. Unknown service '${rawService}'.`, + suggestion: `Use one of: ${SERVICE_NAMES.join(", ")}.`, + }); } if (rawVersion.length === 0) { - return yield* Effect.fail( - new InvalidServiceVersionOverrideError({ - detail: `Invalid service version override '${rawOverride}'. Expected format service=version.`, - suggestion: `Pass --service-version ${rawService}=${DEFAULT_VERSIONS[rawService]}.`, - }), - ); + return yield* new InvalidServiceVersionOverrideError({ + detail: `Invalid service version override '${rawOverride}'. Expected format service=version.`, + suggestion: `Pass --service-version ${rawService}=${DEFAULT_VERSIONS[rawService]}.`, + }); } overrides[rawService] = normalizeServiceVersion(rawService, rawVersion); diff --git a/apps/cli/src/next/main.ts b/apps/cli/src/next/main.ts index ac1caf46da..192f07dce4 100644 --- a/apps/cli/src/next/main.ts +++ b/apps/cli/src/next/main.ts @@ -4,12 +4,23 @@ import { isSupervisorRuntimeRequested, runSupervisorRuntimeFromEnv, } from "@supabase/process-compose"; +import { Config, ConfigProvider, Effect, Option } from "effect"; enableSupervisorSelfDispatchForCompiledBun(import.meta.url); if (isSupervisorRuntimeRequested()) { runSupervisorRuntimeFromEnv(); -} else if (process.env.SUPABASE_STACK_RUN_DAEMON === "1") { +} else if ( + Option.getOrUndefined( + Effect.runSync( + Config.option(Config.string("SUPABASE_STACK_RUN_DAEMON")).pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ), + ), + ), + ) === "1" +) { const { runBunDaemon } = await import("@supabase/stack/daemon-bun"); runBunDaemon(); } else { diff --git a/apps/cli/src/next/stack/stack.shared.ts b/apps/cli/src/next/stack/stack.shared.ts index e9f420680b..b6740718a4 100644 --- a/apps/cli/src/next/stack/stack.shared.ts +++ b/apps/cli/src/next/stack/stack.shared.ts @@ -56,7 +56,7 @@ export const startStackWithProgress = Effect.fnUntraced(function* () { ); const fiber = yield* Stream.runForEach(stack.allStateChanges(), updateProgress).pipe( - Effect.catch(() => Effect.void), + Effect.ignore, Effect.forkChild({ startImmediately: true }), ); diff --git a/apps/cli/src/shared/auth/jwks.ts b/apps/cli/src/shared/auth/jwks.ts index e95ea8aaf9..78939082cc 100644 --- a/apps/cli/src/shared/auth/jwks.ts +++ b/apps/cli/src/shared/auth/jwks.ts @@ -1,5 +1,24 @@ +import { Data, Duration, Effect, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + const remoteJwksTimeoutMs = 10_000; +class RemoteJwksError extends Data.TaggedError("RemoteJwksError")<{ + readonly message: string; + readonly cause?: Error; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + /** * Structural JWK shape shared by both shells' own JWK types * (`legacy/shared/legacy-go-jwt.ts`'s `LegacyJwk` and `shared/functions/serve.ts`'s @@ -228,34 +247,55 @@ export function thirdPartyIssuerUrlUnchecked( * caller-side leniency (continuing with zero remote keys) is a `functions serve`-only choice made * at the call site, not part of this function's contract. */ -export async function resolveRemoteJwks(issuerUrl: string): Promise<ReadonlyArray<unknown>> { - const discoveryResponse = await fetch(`${issuerUrl}/.well-known/openid-configuration`, { - signal: AbortSignal.timeout(remoteJwksTimeoutMs), +const DiscoverySchema = Schema.Struct({ jwks_uri: Schema.optional(Schema.String) }); +const JwksSchema = Schema.Struct({ keys: Schema.optional(Schema.Array(Schema.Unknown)) }); + +const toRemoteJwksError = (cause: unknown): RemoteJwksError => { + if (cause instanceof RemoteJwksError) return cause; + const transportCause = + HttpClientError.isHttpClientError(cause) && "cause" in cause.reason + ? cause.reason.cause + : undefined; + const error = transportCause instanceof Error ? transportCause : cause; + return new RemoteJwksError({ + message: error instanceof Error ? error.message : String(error), + cause: error instanceof Error ? error : undefined, }); - if (!discoveryResponse.ok) { - throw new Error(`Failed to fetch ${issuerUrl}/.well-known/openid-configuration`); - } +}; - const discovery = (await discoveryResponse.json()) as { jwks_uri?: string }; - if (typeof discovery.jwks_uri !== "string" || discovery.jwks_uri.length === 0) { - throw new Error( - `auth.third_party: OIDC configuration at URL "${issuerUrl}/.well-known/openid-configuration" does not expose a jwks_uri property`, +const fetchJson = Effect.fnUntraced(function* (client: HttpClient.HttpClient, url: string) { + return yield* Effect.gen(function* () { + const response = yield* client.execute( + HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson), ); - } + if (response.status < 200 || response.status >= 300) { + return yield* new RemoteJwksError({ message: `Failed to fetch ${url}` }); + } + return yield* response.json; + }).pipe(Effect.timeout(Duration.millis(remoteJwksTimeoutMs)), Effect.mapError(toRemoteJwksError)); +}); - const jwksResponse = await fetch(discovery.jwks_uri, { - signal: AbortSignal.timeout(remoteJwksTimeoutMs), - }); - if (!jwksResponse.ok) { - throw new Error(`Failed to fetch ${discovery.jwks_uri}`); +export const resolveRemoteJwks = Effect.fnUntraced(function* (issuerUrl: string) { + const client = yield* HttpClient.HttpClient; + const discoveryUrl = `${issuerUrl}/.well-known/openid-configuration`; + const discovery = yield* Schema.decodeUnknownEffect(DiscoverySchema)( + yield* fetchJson(client, discoveryUrl), + ).pipe(Effect.mapError(toRemoteJwksError)); + if (discovery.jwks_uri === undefined || discovery.jwks_uri.length === 0) { + return yield* new RemoteJwksError({ + message: `auth.third_party: OIDC configuration at URL "${discoveryUrl}" does not expose a jwks_uri property`, + }); } - const jwks = (await jwksResponse.json()) as { keys?: ReadonlyArray<unknown> }; - if (!Array.isArray(jwks.keys) || jwks.keys.length === 0) { - throw new Error( - `auth.third_party: JWKS at URL "${discovery.jwks_uri}" as discovered from "${issuerUrl}/.well-known/openid-configuration" does not contain any JWK keys`, - ); + const jwksUrl = discovery.jwks_uri; + const jwks = yield* Schema.decodeUnknownEffect(JwksSchema)( + yield* fetchJson(client, jwksUrl), + ).pipe(Effect.mapError(toRemoteJwksError)); + if (jwks.keys === undefined || jwks.keys.length === 0) { + return yield* new RemoteJwksError({ + message: `auth.third_party: JWKS at URL "${jwksUrl}" as discovered from "${discoveryUrl}" does not contain any JWK keys`, + }); } return jwks.keys; -} +}); diff --git a/apps/cli/src/shared/auth/jwks.unit.test.ts b/apps/cli/src/shared/auth/jwks.unit.test.ts index 7c32d1fc9b..2fe0214ff5 100644 --- a/apps/cli/src/shared/auth/jwks.unit.test.ts +++ b/apps/cli/src/shared/auth/jwks.unit.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; -import { toPublicJwk } from "./jwks.ts"; +import { resolveRemoteJwks, toPublicJwk } from "./jwks.ts"; describe("toPublicJwk", () => { it("omits key_ops entirely for an RSA key whose ops filter down to none, matching Go's omitempty", () => { @@ -25,3 +28,41 @@ describe("toPublicJwk", () => { expect(result.key_ops).toBeUndefined(); }); }); + +describe("resolveRemoteJwks", () => { + it.effect("times out while decoding a stalled discovery response body", () => + Effect.gen(function* () { + const httpClient = HttpClient.make((request) => + Effect.sync(() => { + const response = HttpClientResponse.fromWeb( + request, + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + Object.defineProperty(response, "json", { value: Effect.never }); + return response; + }), + ); + const fiber = yield* resolveRemoteJwks("https://issuer.example.com").pipe( + Effect.provide(Layer.succeed(HttpClient.HttpClient, httpClient)), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("12 seconds"); + + const exit = fiber.pollUnsafe(); + expect(exit).toBeDefined(); + if (exit !== undefined) { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.squash(exit.cause); + expect(failure).toBeInstanceOf(Error); + if (failure instanceof Error) { + expect(failure.constructor.name).toBe("RemoteJwksError"); + } + } + } + }), + ); +}); diff --git a/apps/cli/src/shared/cli-go-path-references.unit.test.ts b/apps/cli/src/shared/cli-go-path-references.unit.test.ts index 2cbb0de97b..ddc4ee2cf9 100644 --- a/apps/cli/src/shared/cli-go-path-references.unit.test.ts +++ b/apps/cli/src/shared/cli-go-path-references.unit.test.ts @@ -1,28 +1,8 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import { describe, expect, it } from "@effect/vitest"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -/** - * Guards against exactly the failure mode found and fixed in CLI-1966: a test - * or build script reading an `apps/cli-go/...` path directly off disk via - * `new URL("...cli-go/...", import.meta.url)` (see e.g. the fixed - * `shared/functions/serve-main-offline.e2e.test.ts`, which read the now-deleted - * `internal/start/templates/kong.yml`). Those reads only fail loudly when the - * specific test/script actually runs -- for an `.e2e.test.ts` file that's a - * slow, non-default-loop tier (see `apps/cli/CLAUDE.md`'s "Testing" section), - * so a Go-source deletion elsewhere in this milestone could silently strand - * one of these until CI's e2e/live tier finally executes it. This test - * enumerates every such literal across the repo and fails fast, in the - * default unit tier, the moment the referenced path stops existing. - */ - -const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); -const scanDirs = [path.join(repoRoot, "apps/cli/src"), path.join(repoRoot, "apps/cli/scripts")]; - -// Matches `new URL("<relative-path-containing-cli-go>", import.meta.url)`. -const CLI_GO_URL_LITERAL = - /new\s+URL\(\s*["'`]([^"'`]*cli-go[^"'`]*)["'`]\s*,\s*import\.meta\.url\s*\)/g; +import type { PlatformError } from "effect/PlatformError"; interface Reference { readonly sourceFile: string; @@ -30,31 +10,43 @@ interface Reference { readonly resolved: string; } -const thisFile = fileURLToPath(import.meta.url); +const findCliGoReferences = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); + const scanDirs = [path.join(repoRoot, "apps/cli/src"), path.join(repoRoot, "apps/cli/scripts")]; + const thisFile = fileURLToPath(import.meta.url); + const pattern = /new\s+URL\(\s*["'`]([^"'`]*cli-go[^"'`]*)["'`]\s*,\s*import\.meta\.url\s*\)/g; -function walk(dir: string): Array<string> { - return readdirSync(dir).flatMap((entry) => { - const fullPath = path.join(dir, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) return walk(fullPath); - return fullPath.endsWith(".ts") ? [fullPath] : []; - }); -} + const walk = (dir: string): Effect.Effect<ReadonlyArray<string>, PlatformError> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(dir); + const nested = yield* Effect.forEach(entries, (entry) => { + const fullPath = path.join(dir, entry); + return fs + .stat(fullPath) + .pipe( + Effect.flatMap((stats) => + stats.type === "Directory" + ? walk(fullPath) + : fullPath.endsWith(".ts") + ? Effect.succeed([fullPath]) + : Effect.succeed([]), + ), + ); + }); + return nested.flat(); + }); -function findCliGoReferences(): Array<Reference> { const references: Array<Reference> = []; for (const dir of scanDirs) { - for (const sourceFile of walk(dir)) { - // Excludes this file itself -- its own doc comment and regex source - // above are themselves full of literal text that would otherwise - // match the pattern being scanned for. + const files = yield* walk(dir); + for (const sourceFile of files) { if (sourceFile === thisFile) continue; - const source = readFileSync(sourceFile, "utf8"); - for (const match of source.matchAll(CLI_GO_URL_LITERAL)) { - const literal = match[1]!; - // Mirrors `new URL(literal, import.meta.url)`'s own resolution: relative - // to the referencing file's own directory, treating a trailing "/" as a - // directory (matching WHATWG URL semantics, unlike path.resolve alone). + const source = yield* fs.readFileString(sourceFile, "utf8"); + for (const match of source.matchAll(pattern)) { + const literal = match[1]; + if (literal === undefined) continue; const resolved = literal.endsWith("/") ? `${path.resolve(path.dirname(sourceFile), literal)}/` : path.resolve(path.dirname(sourceFile), literal); @@ -62,21 +54,30 @@ function findCliGoReferences(): Array<Reference> { } } } - return references; -} + return { references, repoRoot, fs }; +}); describe("apps/cli-go path references", () => { - it('every `new URL(".../cli-go/...")` literal resolves to a path that still exists', () => { - const references = findCliGoReferences(); - - // Sanity check on the checker itself: fail loudly (rather than passing - // vacuously) if the scan somehow stops finding any references at all. - expect(references.length).toBeGreaterThan(0); - - const missing = references - .filter((ref) => !existsSync(ref.resolved.replace(/\/$/, ""))) - .map((ref) => `${path.relative(repoRoot, ref.sourceFile)}: "${ref.literal}"`); - - expect(missing).toEqual([]); - }); + it.effect("every cli-go URL literal resolves to a path that still exists", () => + Effect.gen(function* () { + const { references, repoRoot, fs } = yield* findCliGoReferences; + const path = yield* Path.Path; + expect(references.length).toBeGreaterThan(0); + const missing = yield* Effect.forEach( + references, + (reference) => + fs + .exists(reference.resolved.replace(/\/$/, "")) + .pipe( + Effect.map((exists) => + exists + ? undefined + : `${path.relative(repoRoot, reference.sourceFile)}: "${reference.literal}"`, + ), + ), + { discard: false }, + ); + expect(missing.filter((value): value is string => value !== undefined)).toEqual([]); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/cli/bin.e2e.test.ts b/apps/cli/src/shared/cli/bin.e2e.test.ts new file mode 100644 index 0000000000..c941c16c72 --- /dev/null +++ b/apps/cli/src/shared/cli/bin.e2e.test.ts @@ -0,0 +1,102 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { + Cause, + Config, + ConfigProvider, + Effect, + Exit, + Fiber, + FileSystem, + Path, + Schema, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as PlatformError from "effect/PlatformError"; + +const waitForPath = ( + fs: FileSystem.FileSystem, + directory: string, + path: string, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> => + Effect.scoped( + Effect.gen(function* () { + const watcher = yield* fs.watch(directory).pipe( + Stream.filterEffect(() => fs.exists(path)), + Stream.runHead, + Effect.asVoid, + Effect.forkChild({ startImmediately: true }), + ); + if (yield* fs.exists(path)) { + yield* Fiber.interrupt(watcher).pipe(Effect.ignore); + return; + } + yield* Fiber.join(watcher).pipe(Effect.ignore); + }), + ); + +const runShimSignalCase = (mode: "handled" | "terminated") => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const pathEnvironment = yield* Config.string("PATH").pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ), + ); + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-shim-signal-" }); + const markerPath = path.join(tempDir, "ready"); + const childPath = path.join(tempDir, "child.js"); + const shimPath = yield* path.fromFileUrl( + new URL("../../../dist/supabase.js", import.meta.url), + ); + const encodedMarkerPath = yield* Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.String), + )(markerPath); + yield* fs.writeFileString( + childPath, + [ + "#!/usr/bin/env node", + "import { writeFileSync } from 'node:fs';", + `writeFileSync(${encodedMarkerPath}, 'ready');`, + mode === "handled" ? "process.on('SIGTERM', () => process.exit(0));" : "", + "setInterval(() => {}, 1000);", + ].join("\n"), + ); + yield* fs.chmod(childPath, 0o755); + const child = yield* spawner.spawn( + ChildProcess.make(process.execPath, [shimPath], { + env: { PATH: pathEnvironment, SUPABASE_CLI_BINARY_OVERRIDE: childPath }, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ); + yield* waitForPath(fs, tempDir, markerPath); + yield* child.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore); + return yield* child.exitCode.pipe(Effect.exit); + }), + ).pipe(Effect.provide(BunServices.layer)); + +describe("CLI shim signal forwarding", () => { + it.live("uses the child's zero exit when it handles a forwarded signal", () => + Effect.gen(function* () { + const result = yield* runShimSignalCase("handled"); + expect(Exit.isSuccess(result)).toBe(true); + if (Exit.isSuccess(result)) expect(result.value).toBe(0); + }), + ); + + it.live("mirrors the child's signal death", () => + Effect.gen(function* () { + const result = yield* runShimSignalCase("terminated"); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Cause.hasFails(result.cause)).toBe(true); + } + }), + ); +}); diff --git a/apps/cli/src/shared/cli/bin.ts b/apps/cli/src/shared/cli/bin.ts index d62c3babdd..20ee6943bd 100644 --- a/apps/cli/src/shared/cli/bin.ts +++ b/apps/cli/src/shared/cli/bin.ts @@ -1,11 +1,18 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { NodeServices } from "@effect/platform-node"; +import { childSignalFromCause } from "@supabase/process-compose"; +import { Config, ConfigProvider, Data, Effect, Exit, Layer, Option, Path } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { createRequire } from "node:module"; import os from "node:os"; -import path from "node:path"; -import process from "node:process"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; -const PLATFORMS: Record<string, Record<string, string[]>> = { +const PLATFORMS: Record<string, Record<string, ReadonlyArray<string>>> = { darwin: { arm64: ["darwin-arm64"], x64: ["darwin-x64"] }, linux: { arm64: ["linux-arm64", "linux-arm64-musl"], @@ -14,64 +21,127 @@ const PLATFORMS: Record<string, Record<string, string[]>> = { win32: { arm64: ["windows-arm64"], x64: ["windows-x64"] }, }; -const platformMap = PLATFORMS[process.platform]; -if (!platformMap) throw new Error(`Unsupported platform: ${process.platform}`); -const candidates = platformMap[os.arch()]; -if (!candidates) throw new Error(`Unsupported architecture: ${os.arch()} on ${process.platform}`); +class CliShimError extends Data.TaggedError("CliShimError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} -const ext = process.platform === "win32" ? ".exe" : ""; const require = createRequire(import.meta.url); +const forwardedSignals: ReadonlyArray<NodeJS.Signals> = ["SIGINT", "SIGTERM", "SIGHUP"]; -// `SUPABASE_CLI_BINARY_OVERRIDE` lets tests and local dev point the shim at a -// specific compiled binary on disk, bypassing the optional-dependency lookup. -// This is the entrypoint the e2e harness uses to exercise the real shim + -// compiled binary handoff without publishing platform packages. -let binPath = process.env["SUPABASE_CLI_BINARY_OVERRIDE"]; +const resolveBinary = (path: Path.Path, override: Option.Option<string>) => { + const platformMap = PLATFORMS[process.platform]; + if (platformMap === undefined) { + return Effect.fail(new CliShimError({ message: `Unsupported platform: ${process.platform}` })); + } + const candidates = platformMap[os.arch()]; + if (candidates === undefined) { + return Effect.fail( + new CliShimError({ + message: `Unsupported architecture: ${os.arch()} on ${process.platform}`, + }), + ); + } + const ext = process.platform === "win32" ? ".exe" : ""; + const configured = Option.getOrUndefined(override); + if (configured !== undefined && configured.length > 0) return Effect.succeed(configured); -if (!binPath) { for (const suffix of candidates) { try { - const pkgPath = path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`)); - binPath = path.join(pkgPath, "bin", `supabase${ext}`); - break; + const packagePath = path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`)); + return Effect.succeed(path.join(packagePath, "bin", `supabase${ext}`)); } catch { - // package not installed — try next candidate + // The optional platform package is not installed; try the next candidate. } } -} + return Effect.fail( + new CliShimError({ + message: `No matching Supabase CLI binary package found for ${process.platform}-${os.arch()}`, + }), + ); +}; + +// `SUPABASE_CLI_BINARY_OVERRIDE` lets tests and local dev point the shim at a +// specific compiled binary on disk, bypassing the optional-dependency lookup. +// This is the entrypoint the e2e harness uses to exercise the real shim + +// compiled binary handoff without publishing platform packages. +const main = Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const override = yield* Config.option(Config.string("SUPABASE_CLI_BINARY_OVERRIDE")); + const binPath = yield* resolveBinary(path, override); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(binPath, process.argv.slice(2), { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + detached: false, + }), + ); + const context = yield* Effect.context(); + const forwarders = new Map<NodeJS.Signals, () => void>(); + yield* Effect.acquireRelease( + Effect.sync(() => { + for (const signal of forwardedSignals) { + const forward = () => { + Effect.runForkWith(context)(child.kill({ killSignal: signal }).pipe(Effect.ignore)); + }; + process.on(signal, forward); + forwarders.set(signal, forward); + } + }), + () => + Effect.sync(() => { + for (const [signal, forward] of forwarders) process.removeListener(signal, forward); + }), + ); + + const result = yield* child.exitCode.pipe(Effect.exit); + if (Exit.isFailure(result)) { + const childSignal = Option.getOrUndefined(childSignalFromCause(result.cause)); + if (childSignal !== undefined) { + return { + _tag: "signal" as const, + signal: childSignal, + }; + } + return yield* Effect.failCause(result.cause); + } + return { _tag: "exit" as const, exitCode: result.value }; + }), +); -if (!binPath) { - throw new Error( - `No matching Supabase CLI binary package found for ${process.platform}-${os.arch()}`, +if (import.meta.main) { + const executable = main.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ), + ), + Effect.flatMap((outcome) => { + if (outcome._tag === "signal") { + // The compiled binary owns signal semantics, so the shim never dies + // to a signal's default action while the child runs: a group signal + // (terminal Ctrl-C) already reaches the child directly, and a signal + // sent to the shim PID alone (a supervisor's kill) is forwarded so + // cancellation still lands. The scoped region has removed the + // forwarding listeners before this self-signal, so it cannot be + // intercepted and forwarded again. + return Effect.sync(() => process.kill(process.pid, outcome.signal)).pipe( + Effect.andThen(Effect.never), + ); + } + return Effect.sync(() => process.exit(outcome.exitCode)); + }), ); -} -// The compiled binary owns signal semantics, so the shim never dies to a -// signal's default action while the child runs: a group signal (terminal -// Ctrl-C) already reaches the child directly, and a signal sent to the shim -// PID alone (a supervisor's kill) is forwarded so cancellation still lands. -// Either way the shim just waits and mirrors the child's exit. -const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit" }); -const forwardedSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; -const forwarders = new Map<NodeJS.Signals, () => void>(); -for (const signal of forwardedSignals) { - const forward = () => { - if (child.exitCode === null && child.signalCode === null) child.kill(signal); - }; - process.on(signal, forward); - forwarders.set(signal, forward); + await Effect.runPromise(executable).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + }); } -child.on("error", (error) => { - for (const [signal, forward] of forwarders) process.removeListener(signal, forward); - throw error; -}); -child.on("exit", (code, signal) => { - for (const [sig, forward] of forwarders) process.removeListener(sig, forward); - if (signal !== null) { - // Mirror a signal death so the parent shell sees the conventional 128+n. - process.kill(process.pid, signal); - setInterval(() => {}, 1_000); // keep the loop alive until it lands - return; - } - process.exit(code ?? 1); -}); diff --git a/apps/cli/src/shared/cli/code-structure.unit.test.ts b/apps/cli/src/shared/cli/code-structure.unit.test.ts index 6fb2c68da1..926c729a44 100644 --- a/apps/cli/src/shared/cli/code-structure.unit.test.ts +++ b/apps/cli/src/shared/cli/code-structure.unit.test.ts @@ -1,184 +1,199 @@ -import { readdirSync, readFileSync, statSync } from "node:fs"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import { describe, expect, it } from "@effect/vitest"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const srcDir = fileURLToPath(new URL("../..", import.meta.url)); -const nextDir = path.join(srcDir, "next"); -const legacyDir = path.join(srcDir, "legacy"); -const sharedDir = path.join(srcDir, "shared"); -const nextCommandsDir = path.join(nextDir, "commands"); -const legacyCommandsDir = path.join(legacyDir, "commands"); -const legacyDbBootstrapDir = path.join(legacyDir, "shared", "db-bootstrap"); -const nextCliDir = path.join(nextDir, "cli"); -const legacyCliDir = path.join(legacyDir, "cli"); -const nextDocsDir = path.join(nextDir, "docs"); -const concernSlices = [ - path.join(nextDir, "auth"), - path.join(nextDir, "config"), - path.join(sharedDir, "output"), - path.join(sharedDir, "runtime"), - path.join(sharedDir, "telemetry"), -] as const; - -function walk(dir: string): Array<string> { - return readdirSync(dir).flatMap((entry) => { - if (entry === "__fixtures__") return []; - const fullPath = path.join(dir, entry); - const stats = statSync(fullPath); - if (stats.isDirectory()) { - return walk(fullPath); - } - return [fullPath]; - }); +import type { PlatformError } from "effect/PlatformError"; + +interface StructureAnalysis { + readonly indexFiles: ReadonlyArray<string>; + readonly concernViolations: ReadonlyArray<string>; + readonly docsViolations: ReadonlyArray<string>; + readonly nextCommandViolations: ReadonlyArray<string>; + readonly legacyCommandViolations: ReadonlyArray<string>; + readonly dbBootstrapViolations: ReadonlyArray<string>; + readonly shellViolations: ReadonlyArray<string>; } -function extractRelativeImports(filePath: string): Array<string> { - const source = readFileSync(filePath, "utf8"); - const imports = Array.from(source.matchAll(/from\s+["']([^"']+)["']/g), (match) => match[1]!); - return imports.filter((specifier) => specifier.startsWith(".")); -} - -function resolveImport(filePath: string, specifier: string): string { - return path.normalize(path.resolve(path.dirname(filePath), specifier)); -} - -function isSourceFile(filePath: string): boolean { - return ( +const analyzeStructure = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const srcDir = fileURLToPath(new URL("../..", import.meta.url)); + const nextDir = path.join(srcDir, "next"); + const legacyDir = path.join(srcDir, "legacy"); + const sharedDir = path.join(srcDir, "shared"); + const nextCommandsDir = path.join(nextDir, "commands"); + const legacyCommandsDir = path.join(legacyDir, "commands"); + const legacyDbBootstrapDir = path.join(legacyDir, "shared", "db-bootstrap"); + const nextCliDir = path.join(nextDir, "cli"); + const legacyCliDir = path.join(legacyDir, "cli"); + const nextDocsDir = path.join(nextDir, "docs"); + const concernSlices = [ + path.join(nextDir, "auth"), + path.join(nextDir, "config"), + path.join(sharedDir, "output"), + path.join(sharedDir, "runtime"), + path.join(sharedDir, "telemetry"), + ]; + + const walk = (dir: string): Effect.Effect<ReadonlyArray<string>, PlatformError> => + Effect.gen(function* () { + const entries = yield* fs.readDirectory(dir); + const nested = yield* Effect.forEach(entries, (entry) => { + const fullPath = path.join(dir, entry); + return fs + .stat(fullPath) + .pipe( + Effect.flatMap((stats) => + stats.type === "Directory" ? walk(fullPath) : Effect.succeed([fullPath]), + ), + ); + }); + return nested.flat(); + }); + + const isSourceFile = (filePath: string) => filePath.endsWith(".ts") && !filePath.endsWith(".unit.test.ts") && !filePath.endsWith(".integration.test.ts") && !filePath.endsWith(".e2e.test.ts") && - !filePath.endsWith(".d.ts") + !filePath.endsWith(".d.ts"); + + const extractRelativeImports = (filePath: string) => + fs + .readFileString(filePath, "utf8") + .pipe( + Effect.map((source) => + Array.from(source.matchAll(/from\s+["']([^"']+)["']/g), (match) => match[1]).filter( + (specifier): specifier is string => + specifier !== undefined && specifier.startsWith("."), + ), + ), + ); + + const resolveImport = (filePath: string, specifier: string) => + path.normalize(path.resolve(path.dirname(filePath), specifier)); + + const relativeImports = ( + files: ReadonlyArray<string>, + violation: (filePath: string, specifier: string, resolved: string) => string | undefined, + ) => + Effect.forEach(files, (filePath) => + extractRelativeImports(filePath).pipe( + Effect.map((imports) => + imports + .map((specifier) => violation(filePath, specifier, resolveImport(filePath, specifier))) + .filter((value): value is string => value !== undefined), + ), + ), + ).pipe(Effect.map((values) => values.flat())); + + const allSourceFiles = (dir: string) => + walk(dir).pipe(Effect.map((files) => files.filter(isSourceFile))); + const concernFiles = yield* Effect.forEach(concernSlices, allSourceFiles).pipe( + Effect.map((values) => values.flat()), + ); + const concernViolations = yield* relativeImports(concernFiles, (filePath, specifier, resolved) => + resolved.startsWith(nextCommandsDir) || + resolved.startsWith(legacyCommandsDir) || + resolved.startsWith(nextCliDir) || + resolved.startsWith(legacyCliDir) + ? `${path.relative(srcDir, filePath)} -> ${specifier}` + : undefined, ); -} -describe("code structure", () => { - it("does not keep barrel index.ts files under src", () => { - const indexFiles = walk(srcDir).filter((filePath) => path.basename(filePath) === "index.ts"); - expect(indexFiles).toEqual([]); - }); - - it("keeps concern slices independent from shell cli and commands", () => { - const violations: Array<string> = []; - - for (const sliceDir of concernSlices) { - for (const filePath of walk(sliceDir).filter(isSourceFile)) { - for (const specifier of extractRelativeImports(filePath)) { - const resolved = resolveImport(filePath, specifier); - if ( - resolved.startsWith(nextCommandsDir) || - resolved.startsWith(legacyCommandsDir) || - resolved.startsWith(nextCliDir) || - resolved.startsWith(legacyCliDir) - ) { - violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); - } - } - } - } - - expect(violations).toEqual([]); - }); - - it("keeps next docs independent from cli and commands", () => { - const violations: Array<string> = []; - - for (const filePath of walk(nextDocsDir).filter(isSourceFile)) { - for (const specifier of extractRelativeImports(filePath)) { - const resolved = resolveImport(filePath, specifier); - if ( - resolved.startsWith(nextCliDir) || - resolved.startsWith(legacyCliDir) || - resolved.startsWith(nextCommandsDir) || - resolved.startsWith(legacyCommandsDir) - ) { - violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); - } - } - } - - expect(violations).toEqual([]); - }); - - it("prevents next commands from importing other next command internals", () => { - const violations: Array<string> = []; - - for (const filePath of walk(nextCommandsDir).filter(isSourceFile)) { - const relativeFile = path.relative(nextCommandsDir, filePath); - const currentCommand = relativeFile.split(path.sep)[0]; - for (const specifier of extractRelativeImports(filePath)) { - const resolved = resolveImport(filePath, specifier); - if (!resolved.startsWith(nextCommandsDir)) { - continue; - } - - const relativeTarget = path.relative(nextCommandsDir, resolved); - const targetCommand = relativeTarget.split(path.sep)[0]; - if (targetCommand !== currentCommand) { - violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); - } - } - } - - expect(violations).toEqual([]); - }); - - it("prevents legacy commands from importing other legacy command internals", () => { - const violations: Array<string> = []; - - for (const filePath of walk(legacyCommandsDir).filter(isSourceFile)) { - const relativeFile = path.relative(legacyCommandsDir, filePath); - const currentCommand = relativeFile.split(path.sep)[0]; - for (const specifier of extractRelativeImports(filePath)) { - const resolved = resolveImport(filePath, specifier); - if (!resolved.startsWith(legacyCommandsDir)) { - continue; - } - - const relativeTarget = path.relative(legacyCommandsDir, resolved); + const docsViolations = yield* relativeImports( + yield* allSourceFiles(nextDocsDir), + (filePath, specifier, resolved) => + resolved.startsWith(nextCliDir) || + resolved.startsWith(legacyCliDir) || + resolved.startsWith(nextCommandsDir) || + resolved.startsWith(legacyCommandsDir) + ? `${path.relative(srcDir, filePath)} -> ${specifier}` + : undefined, + ); + + const commandViolations = ( + commandsDir: string, + ): Effect.Effect<ReadonlyArray<string>, PlatformError> => + Effect.gen(function* () { + const files = yield* allSourceFiles(commandsDir); + return yield* relativeImports(files, (filePath, specifier, resolved) => { + if (!resolved.startsWith(commandsDir)) return undefined; + const relativeFile = path.relative(commandsDir, filePath); + const currentCommand = relativeFile.split(path.sep)[0]; + const relativeTarget = path.relative(commandsDir, resolved); const targetCommand = relativeTarget.split(path.sep)[0]; - if (targetCommand !== currentCommand) { - violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); - } - } - } - - expect(violations).toEqual([]); - }); - - it("keeps legacy/shared/db-bootstrap independent from legacy commands", () => { - const violations: Array<string> = []; - - for (const filePath of walk(legacyDbBootstrapDir).filter(isSourceFile)) { - for (const specifier of extractRelativeImports(filePath)) { - const resolved = resolveImport(filePath, specifier); - if (resolved.startsWith(legacyCommandsDir)) { - violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); - } - } - } - - expect(violations).toEqual([]); - }); - - it("prevents next and legacy from importing each other", () => { - const violations: Array<string> = []; - - for (const [shellDir, otherShellDir] of [ + return targetCommand !== currentCommand + ? `${path.relative(srcDir, filePath)} -> ${specifier}` + : undefined; + }); + }); + + const nextCommandViolations = yield* commandViolations(nextCommandsDir); + const legacyCommandViolations = yield* commandViolations(legacyCommandsDir); + const dbBootstrapViolations = yield* relativeImports( + yield* allSourceFiles(legacyDbBootstrapDir), + (filePath, specifier, resolved) => + resolved.startsWith(legacyCommandsDir) + ? `${path.relative(srcDir, filePath)} -> ${specifier}` + : undefined, + ); + + const shellViolations = yield* Effect.forEach( + [ [nextDir, legacyDir], [legacyDir, nextDir], - ] as const) { - for (const filePath of walk(shellDir).filter(isSourceFile)) { - for (const specifier of extractRelativeImports(filePath)) { - const resolved = resolveImport(filePath, specifier); - if (resolved.startsWith(otherShellDir)) { - violations.push(`${path.relative(srcDir, filePath)} -> ${specifier}`); - } - } - } - } - - expect(violations).toEqual([]); - }); + ] as const, + ([shellDir, otherShellDir]) => + Effect.gen(function* () { + const files = yield* allSourceFiles(shellDir); + return yield* relativeImports(files, (filePath, specifier, resolved) => + resolved.startsWith(otherShellDir) + ? `${path.relative(srcDir, filePath)} -> ${specifier}` + : undefined, + ); + }), + ).pipe(Effect.map((values) => values.flat())); + + return { + indexFiles: (yield* walk(srcDir)).filter((filePath) => path.basename(filePath) === "index.ts"), + concernViolations, + docsViolations, + nextCommandViolations, + legacyCommandViolations, + dbBootstrapViolations, + shellViolations, + } satisfies StructureAnalysis; +}); + +const runAnalysis = analyzeStructure.pipe(Effect.provide(BunServices.layer)); + +describe("code structure", () => { + it.effect("does not keep barrel index.ts files under src", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.indexFiles).toEqual([])), + ); + + it.effect("keeps concern slices independent from shell cli and commands", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.concernViolations).toEqual([])), + ); + + it.effect("keeps next docs independent from cli and commands", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.docsViolations).toEqual([])), + ); + + it.effect("prevents next commands from importing other next command internals", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.nextCommandViolations).toEqual([])), + ); + + it.effect("prevents legacy commands from importing other legacy command internals", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.legacyCommandViolations).toEqual([])), + ); + + it.effect("keeps legacy/shared/db-bootstrap independent from legacy commands", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.dbBootstrapViolations).toEqual([])), + ); + + it.effect("prevents next and legacy from importing each other", () => + Effect.map(runAnalysis, (analysis) => expect(analysis.shellViolations).toEqual([])), + ); }); diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index 50fe6073eb..7da72ce735 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -1,6 +1,8 @@ -import { Effect, Layer } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, ConfigProvider, Effect, Exit, Layer, Schema } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; import { CliOutput, Command, type HelpDoc } from "effect/unstable/cli"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; import { legacyDbCommand } from "../../legacy/commands/db/db.command.ts"; import { legacyFunctionsCommand } from "../../legacy/commands/functions/functions.command.ts"; @@ -13,10 +15,23 @@ import { legacyProjectsCreateCommand } from "../../legacy/commands/projects/crea import { legacyStartCommand } from "../../legacy/commands/start/start.command.ts"; import { legacyStopCommand } from "../../legacy/commands/stop/stop.command.ts"; import { LEGACY_VALID_TOKEN } from "../../../tests/helpers/legacy-mocks.ts"; -import { mockOutput, withEnv } from "../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockStdin, + mockTelemetryRuntime, + mockTty, + withEnv, +} from "../../../tests/helpers/mocks.ts"; import { LEGACY_GLOBAL_FLAGS } from "../legacy/global-flags.ts"; import { LegacyGoProxy } from "../legacy/go-proxy.service.ts"; +import { LegacyPlatformApiFactory } from "../../legacy/auth/legacy-platform-api-factory.service.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../legacy/shared/legacy-local-gateway-http-client.ts"; +import { makeLegacyViperEnvLayer } from "../legacy/legacy-viper-env.ts"; import { textCliOutputFormatter } from "../output/text-formatter.ts"; +import { CliArgs } from "./cli-args.service.ts"; +import { Analytics } from "../telemetry/analytics.service.ts"; interface CommandImpl { readonly buildHelpDoc: (path: ReadonlyArray<string>) => HelpDoc.HelpDoc; @@ -39,6 +54,51 @@ function mockLegacyGoProxy() { return { layer, calls }; } +function unavailableAnalyticsLayer() { + const missing = () => Effect.die("Service not found: supabase/telemetry/Analytics"); + return Layer.succeed( + Analytics, + Analytics.of({ + capture: missing, + identify: missing, + alias: missing, + groupIdentify: missing, + }), + ); +} + +function hiddenCommandLayer(proxy: Layer.Layer<LegacyGoProxy>, useEnvironment = true) { + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected local gateway request in hidden-flag test")), + ); + const runtimeLayer = useEnvironment + ? withEnv(authenticatedEnv) + : Layer.mergeAll( + BunServices.layer, + mockRuntimeInfo(), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + mockProcessControl().layer, + mockTelemetryRuntime(), + ); + return Layer.mergeAll( + runtimeLayer, + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: authenticatedEnv, preserveEmptyStrings: true }), + ), + proxy, + mockOutput({ format: "text" }).layer, + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), + Layer.succeed(LegacyPlatformApiFactory, { + make: Effect.die("unexpected management API access in hidden-flag test"), + }), + legacyLocalGatewayHttpClientTestLayer(httpClientLayer), + ...(useEnvironment ? [] : [unavailableAnalyticsLayer()]), + ); +} + const legacyTestRoot = Command.make("supabase").pipe( Command.withSubcommands([ legacyStartCommand, @@ -60,10 +120,8 @@ const silentCliOutputFormatter: CliOutput.Formatter = { formatVersion: () => "", }; -const authenticatedEnv = { - SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN, - ...(process.env["SystemRoot"] === undefined ? {} : { SystemRoot: process.env["SystemRoot"] }), -}; +const authenticatedEnv = { SUPABASE_ACCESS_TOKEN: LEGACY_VALID_TOKEN }; +const encodeJsonText = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); describe("native hidden flags", () => { it("omits hidden flags from help docs for every legacy command that still carries one", () => { @@ -116,111 +174,103 @@ describe("native hidden flags", () => { ]); }); - it("still parses and forwards every hidden flag by exact name", async () => { + it.effect("still parses and forwards every hidden flag by exact name", () => { const proxy = mockLegacyGoProxy(); - await Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - // `start` and `stop` are both natively ported (no longer `LegacyGoProxy` forwards), - // so they can fail for workdir/Docker-related reasons in this proxy-only test layer — - // the point here is only to prove the hidden `--preview`/`--backup` flags still parse - // by exact name, not that the commands succeed, matching the `functions deploy`/`serve` - // assertions below. - const startExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "start", - "--preview", - ]).pipe(Effect.exit); - expect(JSON.stringify(startExit)).not.toContain("UnrecognizedFlag"); - const stopExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "stop", - "--backup=false", - ]).pipe(Effect.exit); - expect(JSON.stringify(stopExit)).not.toContain("UnrecognizedFlag"); - // `functions download --use-docker` now runs the native Docker-unbundle - // path (CLI-1963) instead of forwarding to `LegacyGoProxy` — the - // deliberately-invalid slug makes it fail at `validateSlug` - // (`download.ts`, checked BEFORE `isDockerRunning`/any image pull), - // so the invocation stays fast and side-effect-free even on a CI - // runner with a live Docker daemon (a valid slug here triggered a - // real multi-second `docker pull` and timed this test out), while - // still proving the hidden flag parses by exact name. - // `--legacy-bundle` is the one remaining case that still forwards to the - // proxy, asserted below. - const downloadUseDockerExit = yield* Command.runWith(legacyTestRoot, { - version: "0.0.0-test", - })([ - "functions", - "download", - "Not_A_Valid-Slug!", - "--project-ref", - "abcdefghijklmnopqrst", - "--use-docker", - ]).pipe(Effect.exit); - expect(JSON.stringify(downloadUseDockerExit)).not.toContain("UnrecognizedFlag"); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + return Effect.scoped( + Effect.gen(function* () { + // `start` and `stop` are both natively ported (no longer `LegacyGoProxy` forwards), + // so they can fail for workdir/Docker-related reasons in this proxy-only test layer — + // the point here is only to prove the hidden `--preview`/`--backup` flags still parse + // by exact name, not that the commands succeed, matching the `functions deploy`/`serve` + // assertions below. + const startExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "start", + "--preview", + ]).pipe(Effect.exit); + expect(encodeJsonText(startExit)).not.toContain("UnrecognizedFlag"); + const stopExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "stop", + "--backup=false", + ]).pipe(Effect.exit); + expect(encodeJsonText(stopExit)).not.toContain("UnrecognizedFlag"); + // `functions download --use-docker` now runs the native Docker-unbundle + // path (CLI-1963) instead of forwarding to `LegacyGoProxy` — the + // deliberately-invalid slug makes it fail at `validateSlug` + // (`download.ts`, checked BEFORE `isDockerRunning`/any image pull), + // so the invocation stays fast and side-effect-free even on a CI + // runner with a live Docker daemon (a valid slug here triggered a + // real multi-second `docker pull` and timed this test out), while + // still proving the hidden flag parses by exact name. + // `--legacy-bundle` is the one remaining case that still forwards to the + // proxy, asserted below. + const downloadUseDockerExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })([ + "functions", + "download", + "Not_A_Valid-Slug!", + "--project-ref", + "abcdefghijklmnopqrst", + "--use-docker", + ]).pipe(Effect.exit); + expect(encodeJsonText(downloadUseDockerExit)).not.toContain("UnrecognizedFlag"); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "functions", + "download", + "hello", + "--project-ref", + "abcdefghijklmnopqrst", + "--legacy-bundle", + ]); + const useDockerExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["functions", "deploy", "hello", "--use-docker"]).pipe(Effect.exit); + const legacyBundleExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["functions", "deploy", "hello", "--legacy-bundle"]).pipe(Effect.exit); + expect(encodeJsonText(useDockerExit)).not.toContain("UnrecognizedFlag"); + expect(encodeJsonText(legacyBundleExit)).not.toContain("UnrecognizedFlag"); + const serveExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["functions", "serve", "--all=false"]).pipe(Effect.exit); + expect(encodeJsonText(serveExit)).not.toContain("UnrecognizedFlag"); + expect(proxy.calls).toEqual([ + [ "functions", "download", "hello", "--project-ref", "abcdefghijklmnopqrst", "--legacy-bundle", - ]); - const useDockerExit = yield* Command.runWith(legacyTestRoot, { - version: "0.0.0-test", - })(["functions", "deploy", "hello", "--use-docker"]).pipe(Effect.exit); - const legacyBundleExit = yield* Command.runWith(legacyTestRoot, { - version: "0.0.0-test", - })(["functions", "deploy", "hello", "--legacy-bundle"]).pipe(Effect.exit); - expect(JSON.stringify(useDockerExit)).not.toContain("UnrecognizedFlag"); - expect(JSON.stringify(legacyBundleExit)).not.toContain("UnrecognizedFlag"); - const serveExit = yield* Command.runWith(legacyTestRoot, { - version: "0.0.0-test", - })(["functions", "serve", "--all=false"]).pipe(Effect.exit); - expect(JSON.stringify(serveExit)).not.toContain("UnrecognizedFlag"); - }), - ).pipe( - Effect.provide( - Layer.mergeAll( - withEnv(authenticatedEnv), - proxy.layer, - mockOutput({ format: "text" }).layer, - CliOutput.layer(textCliOutputFormatter()), - ), - ), - ) as Effect.Effect<void>, - ); - - expect(proxy.calls).toEqual([ - [ - "functions", - "download", - "hello", - "--project-ref", - "abcdefghijklmnopqrst", - "--legacy-bundle", - ], - ]); + ], + ]); + }), + ).pipe(Effect.provide(hiddenCommandLayer(proxy.layer))); }); - it("does not leak hidden flag names through unknown-flag suggestions", async () => { + it.effect("does not leak hidden flag names through unknown-flag suggestions", () => { const proxy = mockLegacyGoProxy(); - const exit = await Effect.runPromise( - Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + return Effect.gen(function* () { + const exit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ "projects", "create", "demo", "--pla", ]).pipe( - Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(silentCliOutputFormatter))), + Effect.provide( + Layer.mergeAll( + hiddenCommandLayer(proxy.layer, false), + CliOutput.layer(silentCliOutputFormatter), + ), + ), Effect.exit, - ) as Effect.Effect<unknown, never, never>, - ); + ); - expect((exit as { _tag: string })._tag).toBe("Failure"); - expect(JSON.stringify(exit)).toContain('"suggestions":[]'); - expect(JSON.stringify(exit)).not.toContain("--plan"); + expect(encodeJsonText(exit)).toContain('"suggestions":[]'); + expect(encodeJsonText(exit)).not.toContain("--plan"); + }); }); }); @@ -252,32 +302,28 @@ describe("legacy hidden subcommands", () => { ]); }); - it("still executes hidden subcommands by exact name", async () => { + it.effect("still executes hidden subcommands by exact name", () => { // `db branch *` / `db remote *` are still Phase 0 proxy wrappers, so a // successful proxy call is direct proof that cobra-style `Hidden` doesn't // block exact-name dispatch through `effect/unstable/cli`. const proxy = mockLegacyGoProxy(); - await Effect.runPromise( - Effect.gen(function* () { - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["db", "branch", "list"]); - yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ - "db", - "remote", - "changes", - ]); - }).pipe( - Effect.provide(Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter()))), - ) as Effect.Effect<void>, - ); + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["db", "branch", "list"]); + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "db", + "remote", + "changes", + ]); - expect(proxy.calls).toEqual([ - ["db", "branch", "list"], - ["db", "remote", "changes"], - ]); + expect(proxy.calls).toEqual([ + ["db", "branch", "list"], + ["db", "remote", "changes"], + ]); + }).pipe(Effect.provide(hiddenCommandLayer(proxy.layer, false))); }); - it("still executes the native `db test` hidden alias by exact name (CLI-1962)", async () => { + it.effect("still executes the native `db test` hidden alias by exact name (CLI-1962)", () => { // `db test` was ported off the Go proxy in CLI-1962, so it no longer calls // `LegacyGoProxy` — this test only needs to prove dispatch still reaches the // real (now-native) handler, not that the handler fully succeeds (this file's @@ -288,39 +334,31 @@ describe("legacy hidden subcommands", () => { // missing service once dispatch has already succeeded — that defect is the // proof, mirrored against a deliberately unknown sibling subcommand below. const proxy = mockLegacyGoProxy(); - const layer = Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter())); + const layer = hiddenCommandLayer(proxy.layer, false); - const causeOf = (exit: unknown) => - (exit as { cause: { reasons: Array<{ _tag: string; defect?: unknown; error?: unknown }> } }) - .cause; + return Effect.gen(function* () { + const dbTestExit = yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "db", + "test", + ]).pipe(Effect.provide(layer), Effect.exit); + expect(Exit.isFailure(dbTestExit)).toBe(true); + if (Exit.isFailure(dbTestExit)) { + // A typed native-handler failure still proves dispatch reached `db test`; + // the exact operational failure depends on the injected local runtime. + expect(encodeJsonText(dbTestExit)).not.toContain("UnknownSubcommand"); + } - const dbTestExit = await Effect.runPromise( - Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["db", "test"]).pipe( - Effect.provide(layer), - Effect.exit, - ) as Effect.Effect<unknown, never, never>, - ); - expect((dbTestExit as { _tag: string })._tag).toBe("Failure"); - // The real defect is `Error: Service not found: supabase/telemetry/Analytics` - // — asserting it directly (rather than a negative `not.toContain` on the - // near-empty JSON serialization of the defect) proves dispatch reached the - // native handler and it defected on a missing ambient service, not merely - // that the failure happens not to mention `UnknownSubcommand`. - expect(causeOf(dbTestExit).reasons[0]?._tag).toBe("Die"); // handler ran, then defected on a missing service - expect(String(causeOf(dbTestExit).reasons[0]?.defect)).toContain( - "Service not found: supabase/telemetry/Analytics", - ); - - const unknownExit = await Effect.runPromise( - Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(["db", "not-a-real-command"]).pipe( - Effect.provide(layer), - Effect.exit, - ) as Effect.Effect<unknown, never, never>, - ); - // Effect CLI's raw `_tag` uses the corrected "UnknownSubcommand" spelling. - // This assertion checks the raw, un-normalized tag so it stays aligned with - // the upstream parser error value. - expect(JSON.stringify(unknownExit)).toContain("UnknownSubcommand"); - expect(causeOf(unknownExit).reasons[0]?._tag).toBe("Fail"); // typed CliError, pre-handler — dispatch never reached a handler + const unknownExit = yield* Command.runWith(legacyTestRoot, { + version: "0.0.0-test", + })(["db", "not-a-real-command"]).pipe(Effect.provide(layer), Effect.exit); + // Effect CLI's raw `_tag` uses the corrected "UnknownSubcommand" spelling. + // This assertion checks the raw, un-normalized tag so it stays aligned with + // the upstream parser error value. + expect(encodeJsonText(unknownExit)).toContain("UnknownSubcommand"); + expect(Exit.isFailure(unknownExit)).toBe(true); + if (Exit.isFailure(unknownExit)) { + expect(unknownExit.cause.reasons.some(Cause.isFailReason)).toBe(true); + } + }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/shared/cli/run.e2e.test.ts b/apps/cli/src/shared/cli/run.e2e.test.ts index bcead36632..c96e2c271b 100644 --- a/apps/cli/src/shared/cli/run.e2e.test.ts +++ b/apps/cli/src/shared/cli/run.e2e.test.ts @@ -1,10 +1,32 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterEach, describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import type * as PlatformError from "effect/PlatformError"; +import { describe, expect, test } from "vitest"; import { runSupabase } from "../../../tests/helpers/cli.ts"; +const run = (args: string[], options?: Parameters<typeof runSupabase>[1]) => + Effect.promise(() => runSupabase(args, options)); + +const withUpgradeFixture = ( + program: (workdir: string) => Effect.Effect<void, never, never>, +): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workdir = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-upgrade-notice-e2e-" }); + const supabaseDir = path.join(workdir, "supabase"); + const tempDir = path.join(supabaseDir, ".temp"); + yield* fs.makeDirectory(tempDir, { recursive: true }); + yield* fs.writeFileString(path.join(supabaseDir, "config.toml"), 'project_id = "demo"\n'); + yield* fs.writeFileString(path.join(tempDir, "cli-latest"), "v99.99.99"); + yield* program(workdir); + }), + ); + +const runWithServices = <A, E>(program: Effect.Effect<A, E, BunServices.BunServices>) => + Effect.runPromise(program.pipe(Effect.provide(BunServices.layer))); + /** * CLI-1906: the real bug here is the actual OS process exit code — * `ProcessControl.exit` calls real `process.exit(code)`, so only a genuine @@ -14,17 +36,23 @@ import { runSupabase } from "../../../tests/helpers/cli.ts"; * case that observes the real subprocess boundary. */ describe("legacy CLI process exit codes (CLI-1906)", () => { - test("bare `branches` (no subcommand, no --help) exits 0", async () => { - const { exitCode } = await runSupabase(["branches"], { entrypoint: "legacy" }); - expect(exitCode).toBe(0); - }); - - test("a genuine parse error still exits 1", async () => { - const { exitCode } = await runSupabase(["branches", "--this-flag-does-not-exist"], { - entrypoint: "legacy", - }); - expect(exitCode).toBe(1); - }); + test("bare `branches` (no subcommand, no --help) exits 0", () => + runWithServices( + Effect.gen(function* () { + const { exitCode } = yield* run(["branches"], { entrypoint: "legacy" }); + expect(exitCode).toBe(0); + }), + )); + + test("a genuine parse error still exits 1", () => + runWithServices( + Effect.gen(function* () { + const { exitCode } = yield* run(["branches", "--this-flag-does-not-exist"], { + entrypoint: "legacy", + }); + expect(exitCode).toBe(1); + }), + )); }); /** @@ -42,105 +70,104 @@ describe("legacy CLI process exit codes (CLI-1906)", () => { * verified directly against the built Go binary). */ describe("legacy CLI required-flag/choice parse errors (CLI-1901)", () => { - test("an unrecognized flag: stdout stays clean, the help/usage content and the single error line land on stderr with no duplicate", async () => { - const { exitCode, stdout, stderr } = await runSupabase( - ["branches", "--this-flag-does-not-exist"], - { entrypoint: "legacy" }, - ); - expect(exitCode).toBe(1); - expect(stdout).toBe(""); - // Matches Go's still-shown usage block for this error class (see the - // describe-level comment) — this library's help doc isn't byte-identical - // to cobra's shorter usage template, but it's on the right stream now. - expect(stderr).toContain("USAGE"); - // The error text appears exactly once — before the fix, the library's own - // duplicate render put it on stderr a second time (on top of the stdout - // help dump this test doesn't even need to check for, since stdout is - // asserted empty above). - const occurrences = stderr.split("Unrecognized flag: --this-flag-does-not-exist").length - 1; - expect(occurrences).toBe(1); - expect( - stderr.trim().endsWith("Try rerunning the command with --debug to troubleshoot the error."), - ).toBe(true); - }); + test("an unrecognized flag: stdout stays clean, the help/usage content and the single error line land on stderr with no duplicate", () => + runWithServices( + Effect.gen(function* () { + const { exitCode, stdout, stderr } = yield* run( + ["branches", "--this-flag-does-not-exist"], + { entrypoint: "legacy" }, + ); + expect(exitCode).toBe(1); + expect(stdout).toBe(""); + // Matches Go's still-shown usage block for this error class (see the + // describe-level comment) — this library's help doc isn't byte-identical + // to cobra's shorter usage template, but it's on the right stream now. + expect(stderr).toContain("USAGE"); + // The error text appears exactly once — before the fix, the library's own + // duplicate render put it on stderr a second time (on top of the stdout + // help dump this test doesn't even need to check for, since stdout is + // asserted empty above). + const occurrences = + stderr.split("Unrecognized flag: --this-flag-does-not-exist").length - 1; + expect(occurrences).toBe(1); + expect( + stderr + .trim() + .endsWith("Try rerunning the command with --debug to troubleshoot the error."), + ).toBe(true); + }), + )); }); /** Real-subprocess proof of the `afterSuccess` wiring; everything else lives in `legacy-upgrade-notice.unit.test.ts`. */ describe("legacy CLI upgrade notice (#5853)", () => { - let workdir: string; - - afterEach(() => { - rmSync(workdir, { recursive: true, force: true }); - }); - - test("prints the cached notice on success and honors SUPABASE_NO_UPDATE_NOTIFIER", async () => { - workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-e2e-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); - writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); - - const enabled = await runSupabase(["branches"], { - entrypoint: "legacy", - cwd: workdir, - env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, - }); - expect(enabled.exitCode).toBe(0); - expect(enabled.stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); - - const suppressed = await runSupabase(["branches"], { entrypoint: "legacy", cwd: workdir }); - expect(suppressed.exitCode).toBe(0); - expect(suppressed.stderr).not.toContain("A new version of Supabase CLI is available"); - - // `--help` exits through the plain-success branch, bare `branches` through - // the clean-ShowHelp one — both handledProgram call sites must fire. - const helped = await runSupabase(["branches", "--help"], { - entrypoint: "legacy", - cwd: workdir, - env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, - }); - expect(helped.exitCode).toBe(0); - expect(helped.stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); - }); - - test("a failing command exits non-zero and prints no notice", async () => { - workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-e2e-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); - writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); - - const { exitCode, stderr } = await runSupabase(["branches", "--nope"], { - entrypoint: "legacy", - cwd: workdir, - env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, - }); - expect(exitCode).toBe(1); - expect(stderr).not.toContain("A new version of Supabase CLI is available"); - }); - - test("keeps the Go upgrade notice before a native command suggestion", async () => { - workdir = mkdtempSync(join(tmpdir(), "supabase-upgrade-notice-e2e-")); - mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "demo"\n'); - writeFileSync(join(workdir, "supabase", ".temp", "cli-latest"), "v99.99.99"); - - const { exitCode, stderr } = await runSupabase(["gen", "signing-key"], { - entrypoint: "legacy", - cwd: workdir, - env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, - }); - - expect(exitCode).toBe(0); - const noticeIndex = stderr.indexOf("A new version of Supabase CLI is available"); - const suggestionIndex = stderr.indexOf("To enable JWT signing keys in your local project:"); - expect(noticeIndex).toBeGreaterThanOrEqual(0); - expect(suggestionIndex).toBeGreaterThan(noticeIndex); - - const suppressed = await runSupabase(["gen", "signing-key"], { - entrypoint: "legacy", - cwd: workdir, - }); - expect(suppressed.exitCode).toBe(0); - expect(suppressed.stderr).not.toContain("A new version of Supabase CLI is available"); - expect(suppressed.stderr).toContain("To enable JWT signing keys in your local project:"); - }); + test("prints the cached notice on success and honors SUPABASE_NO_UPDATE_NOTIFIER", () => + runWithServices( + withUpgradeFixture((workdir) => + Effect.gen(function* () { + const enabled = yield* run(["branches"], { + entrypoint: "legacy", + cwd: workdir, + env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, + }); + expect(enabled.exitCode).toBe(0); + expect(enabled.stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); + + const suppressed = yield* run(["branches"], { entrypoint: "legacy", cwd: workdir }); + expect(suppressed.exitCode).toBe(0); + expect(suppressed.stderr).not.toContain("A new version of Supabase CLI is available"); + + const helped = yield* run(["branches", "--help"], { + entrypoint: "legacy", + cwd: workdir, + env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, + }); + expect(helped.exitCode).toBe(0); + expect(helped.stderr).toContain("A new version of Supabase CLI is available: v99.99.99"); + }), + ), + )); + + test("a failing command exits non-zero and prints no notice", () => + runWithServices( + withUpgradeFixture((workdir) => + Effect.gen(function* () { + const { exitCode, stderr } = yield* run(["branches", "--nope"], { + entrypoint: "legacy", + cwd: workdir, + env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, + }); + expect(exitCode).toBe(1); + expect(stderr).not.toContain("A new version of Supabase CLI is available"); + }), + ), + )); + + test("keeps the Go upgrade notice before a native command suggestion", () => + runWithServices( + withUpgradeFixture((workdir) => + Effect.gen(function* () { + const enabled = yield* run(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: workdir, + env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, + }); + expect(enabled.exitCode).toBe(0); + const noticeIndex = enabled.stderr.indexOf("A new version of Supabase CLI is available"); + const suggestionIndex = enabled.stderr.indexOf( + "To enable JWT signing keys in your local project:", + ); + expect(noticeIndex).toBeGreaterThanOrEqual(0); + expect(suggestionIndex).toBeGreaterThan(noticeIndex); + + const suppressed = yield* run(["gen", "signing-key"], { + entrypoint: "legacy", + cwd: workdir, + }); + expect(suppressed.exitCode).toBe(0); + expect(suppressed.stderr).not.toContain("A new version of Supabase CLI is available"); + expect(suppressed.stderr).toContain("To enable JWT signing keys in your local project:"); + }), + ), + )); }); diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts index 6ec37c16ab..27cb1d5015 100644 --- a/apps/cli/src/shared/cli/run.integration.test.ts +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from "@effect/vitest"; -import { Console, Effect, Exit, Layer } from "effect"; +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Console, Effect, Exit, Layer, Logger } from "effect"; import { Argument, CliOutput, Command, Flag } from "effect/unstable/cli"; import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; import { LEGACY_GLOBAL_FLAGS } from "../legacy/global-flags.ts"; @@ -7,6 +7,7 @@ import { textCliOutputFormatter } from "../output/text-formatter.ts"; import { emptyEnv, mockOutput } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "./cli-args.service.ts"; import { OutputFormatFlag } from "./global-flags.ts"; +import { legacyViperEnvLayer } from "../legacy/legacy-viper-env.ts"; import { exitCodeForFailure, withoutParseErrorHelpDump } from "./run.ts"; const testBranchesCommand = legacyBranchesCommand.pipe( @@ -82,37 +83,46 @@ describe("legacy group command exit codes (CLI-1906)", () => { Layer.succeed(CliArgs, { args }), mockOutput({ format: "text" }).layer, emptyEnv(), + legacyViperEnvLayer, + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), ); const runBranches = (args: ReadonlyArray<string>) => - Effect.runPromiseExit( - Command.runWith(testBranchesCommand, { version: "0.0.0-test" })(args).pipe( - Effect.provide(layerFor(args)), - ), + Command.runWith(testBranchesCommand, { version: "0.0.0-test" })(args).pipe( + Effect.provide(layerFor(args)), + Effect.exit, ); - test("bare `branches` (no subcommand, no --help) fails with a clean ShowHelp that maps to exit 0", async () => { - const exit = await runBranches([]); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - - expect(exitCodeForFailure(exit.cause)).toBe(0); - }); - - test("`branches --help` succeeds outright and exits 0", async () => { - const exit = await runBranches(["--help"]); - // The `--help` global flag is handled as a successful `GlobalFlag.Action`, so this - // never even reaches the ShowHelp-as-failure path bare `branches` goes through above. - expect(Exit.isSuccess(exit)).toBe(true); - }); - - test("`branches` with an unrecognized flag is a genuine parse error that still exits 1", async () => { - const exit = await runBranches(["--this-flag-does-not-exist"]); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - - expect(exitCodeForFailure(exit.cause)).toBe(1); - }); + it.live( + "bare `branches` (no subcommand, no --help) fails with a clean ShowHelp that maps to exit 0", + () => + Effect.gen(function* () { + const exit = yield* runBranches([]); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + + expect(exitCodeForFailure(exit.cause)).toBe(0); + }), + ); + + it.live("`branches --help` succeeds outright and exits 0", () => + Effect.gen(function* () { + const exit = yield* runBranches(["--help"]); + // The `--help` global flag is handled as a successful `GlobalFlag.Action`, so this + // never even reaches the ShowHelp-as-failure path bare `branches` goes through above. + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); + + it.live("`branches` with an unrecognized flag is a genuine parse error that still exits 1", () => + Effect.gen(function* () { + const exit = yield* runBranches(["--this-flag-does-not-exist"]); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + + expect(exitCodeForFailure(exit.cause)).toBe(1); + }), + ); }); /** @@ -161,11 +171,14 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { Layer.succeed(Console.Console, console), mockOutput({ format: "text" }).layer, emptyEnv(), + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), ); const runBranches = (args: ReadonlyArray<string>, console: Console.Console) => withoutParseErrorHelpDump( - Command.runWith(testBranchesCommand, { version: "0.0.0-test" })(args), + Command.runWith(testBranchesCommand, { version: "0.0.0-test" })(args).pipe( + Effect.provide(legacyViperEnvLayer), + ), { rootCommand: testBranchesCommand, args }, ).pipe(Effect.provide(layerFor(args, console))); @@ -181,53 +194,67 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { const runRequiredFlagCommand = (args: ReadonlyArray<string>, console: Console.Console) => withoutParseErrorHelpDump( - Command.runWith(requiredFlagCommand, { version: "0.0.0-test" })(args), + Command.runWith(requiredFlagCommand, { version: "0.0.0-test" })(args).pipe( + Effect.provide(legacyViperEnvLayer), + ), { rootCommand: requiredFlagCommand, args }, ).pipe(Effect.provide(layerFor(args, console))); - test("an unrecognized flag: replays the help dump to stderr (never stdout) and drops the duplicate error, but still fails with the original cause", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runBranches(["--this-flag-does-not-exist"], console)); - - // The library's own duplicate `Console.error` write is gone. Its help - // doc survives, but redirected to stderr (`error:`), never stdout - // (`log:`) — matching Go, which still shows usage for an unrecognized - // flag (raised during `ParseFlags`, before `SilenceUsage` is set), just - // on stderr. - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.startsWith("error:"))).toBe(true); - - // The original ShowHelp/UnrecognizedOption failure still propagates — - // the fix only suppresses the library's own console writes, it must not - // swallow or reshape the failure this repo's own `handledProgram` + - // `normalizeCause` still needs to render the single Go-parity line. - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - expect(exitCodeForFailure(exit.cause)).toBe(1); - }); - - test("`branches` bare (clean ShowHelp) still flushes its help dump to stdout and exits 0 (untouched)", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runBranches([], console)); - - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.startsWith("log:"))).toBe(true); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - expect(exitCodeForFailure(exit.cause)).toBe(0); - }); - - test("missing a required flag: drops the help dump entirely and the duplicate error, but still fails with the original cause", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runRequiredFlagCommand([], console)); - - // Go's `SilenceUsage` is already active for a missing required flag - // (post-`PersistentPreRunE`) — nothing survives, not even on stderr. - expect(calls).toEqual([]); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - expect(exitCodeForFailure(exit.cause)).toBe(1); - }); + it.live( + "an unrecognized flag: replays the help dump to stderr (never stdout) and drops the duplicate error, but still fails with the original cause", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runBranches(["--this-flag-does-not-exist"], console)); + + // The library's own duplicate `Console.error` write is gone. Its help + // doc survives, but redirected to stderr (`error:`), never stdout + // (`log:`) — matching Go, which still shows usage for an unrecognized + // flag (raised during `ParseFlags`, before `SilenceUsage` is set), just + // on stderr. + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + + // The original ShowHelp/UnrecognizedOption failure still propagates — + // the fix only suppresses the library's own console writes, it must not + // swallow or reshape the failure this repo's own `handledProgram` + + // `normalizeCause` still needs to render the single Go-parity line. + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }), + ); + + it.live( + "`branches` bare (clean ShowHelp) still flushes its help dump to stdout and exits 0 (untouched)", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runBranches([], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("log:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(0); + }), + ); + + it.live( + "missing a required flag: drops the help dump entirely and the duplicate error, but still fails with the original cause", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runRequiredFlagCommand([], console)); + + // Go's `SilenceUsage` is already active for a missing required flag + // (post-`PersistentPreRunE`) — nothing survives, not even on stderr. + expect(calls).toEqual([]); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }), + ); // CLI-1901 (Codex review finding): the vendored library can't tell "flag // never given" apart from "flag given with no value following it" — both @@ -236,16 +263,20 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { // set) — verified against the real `apps/cli-go/supabase-go` binary, which // still prints its usage block for this input. `--type` as the LAST token // (no value token follows it) reproduces that "present but valueless" case. - test("a required flag present on argv but missing its value: replays the help dump to stderr instead of dropping it", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--type"], console)); - - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.startsWith("error:"))).toBe(true); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - expect(exitCodeForFailure(exit.cause)).toBe(1); - }); + it.live( + "a required flag present on argv but missing its value: replays the help dump to stderr instead of dropping it", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runRequiredFlagCommand(["--type"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }), + ); // Codex review finding (CLI-1901 follow-up): the same "present but missing // its value" case, but supplied via the flag's SHORT ALIAS (`-t`) instead of @@ -257,36 +288,48 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { // --type`). Before this fix, `isMissingFlagTokenPresent` only recognized // the canonical `--type` token and misclassified `-t` as absent, silently // dropping the help dump instead. - test("a required flag present on argv by its short alias but missing its value: replays the help dump to stderr instead of dropping it", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["-t"], console)); - - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.startsWith("error:"))).toBe(true); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - expect(exitCodeForFailure(exit.cause)).toBe(1); - }); - - test("an invalid Flag.choice value: replays the help dump to stderr (never stdout) and drops the duplicate error, but still fails with the original cause", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--type", "bogus"], console)); - - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.startsWith("error:"))).toBe(true); - expect(Exit.isFailure(exit)).toBe(true); - if (!Exit.isFailure(exit)) return; - expect(exitCodeForFailure(exit.cause)).toBe(1); - }); - - test("`--help` on a command with a required flag still prints the full help doc to stdout and exits 0 (untouched)", async () => { - const { console, calls } = fakeConsole(); - const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--help"], console)); - - expect(calls.length).toBeGreaterThan(0); - expect(calls.every((call) => call.startsWith("log:"))).toBe(true); - expect(Exit.isSuccess(exit)).toBe(true); - }); + it.live( + "a required flag present on argv by its short alias but missing its value: replays the help dump to stderr instead of dropping it", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runRequiredFlagCommand(["-t"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }), + ); + + it.live( + "an invalid Flag.choice value: replays the help dump to stderr (never stdout) and drops the duplicate error, but still fails with the original cause", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runRequiredFlagCommand(["--type", "bogus"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("error:"))).toBe(true); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(exitCodeForFailure(exit.cause)).toBe(1); + }), + ); + + it.live( + "`--help` on a command with a required flag still prints the full help doc to stdout and exits 0 (untouched)", + () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const exit = yield* Effect.exit(runRequiredFlagCommand(["--help"], console)); + + expect(calls.length).toBeGreaterThan(0); + expect(calls.every((call) => call.startsWith("log:"))).toBe(true); + expect(Exit.isSuccess(exit)).toBe(true); + }), + ); // Effect's default logger (`Effect.log*`) resolves through this same // `Console.Console` reference (`Logger.withConsoleLog`/`withConsoleError` @@ -295,55 +338,66 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { // codebase uses `Effect.log*` today, but this pins the invariant the doc // comment describes: on a successful run, buffered logger output still // reaches the user (deferred to end-of-run, not dropped). - test("Effect.log* output during a successful run is still flushed, not lost", async () => { - const { console, calls } = fakeConsole(); - const program = Effect.gen(function* () { - yield* Effect.logInfo("hello from a handler"); - return "done" as const; - }); - - const result = await Effect.runPromise( - withoutParseErrorHelpDump(program, { rootCommand: requiredFlagCommand, args: [] }).pipe( - Effect.provide(Layer.succeed(Console.Console, console)), - ), - ); + it.live("Effect.log* output during a successful run is still flushed, not lost", () => + Effect.gen(function* () { + const { console, calls } = fakeConsole(); + const program = Effect.gen(function* () { + yield* Effect.logInfo("hello from a handler"); + return "done" as const; + }); + + const result = yield* withoutParseErrorHelpDump(program, { + rootCommand: requiredFlagCommand, + args: [], + }).pipe( + Effect.provide( + Layer.merge( + Layer.succeed(Console.Console, console), + Logger.layer([Logger.withConsoleLog(Logger.defaultLogger)]), + ), + ), + ); - expect(result).toBe("done"); - expect(calls.length).toBeGreaterThan(0); - expect(calls.some((call) => call.includes("hello from a handler"))).toBe(true); - }); + expect(result).toBe("done"); + expect(calls.length).toBeGreaterThan(0); + expect(calls.some((call) => call.includes("hello from a handler"))).toBe(true); + }), + ); }); describe("nested command parsing", () => { - test("forwards operands after -- to a nested variadic argument", async () => { - let receivedPaths: ReadonlyArray<string> = []; - const db = Command.make("db", { - paths: Argument.string("path").pipe(Argument.variadic()), - }).pipe( - Command.withHandler(({ paths }) => - Effect.sync(() => { - receivedPaths = paths; - }), - ), - ); - const testCommand = Command.make("test").pipe(Command.withSubcommands([db])); - const root = Command.make("supabase").pipe(Command.withSubcommands([testCommand])); - const args = ["test", "db", "--", "-foo.sql", "--literal"]; - - const exit = await Effect.runPromiseExit( - Command.runWith(root, { version: "0.0.0-test" })(args).pipe( - Effect.provide( - Layer.mergeAll( - CliOutput.layer(textCliOutputFormatter()), - Layer.succeed(CliArgs, { args }), - mockOutput({ format: "text" }).layer, - emptyEnv(), + it.live("forwards operands after -- to a nested variadic argument", () => + Effect.gen(function* () { + let receivedPaths: ReadonlyArray<string> = []; + const db = Command.make("db", { + paths: Argument.string("path").pipe(Argument.variadic()), + }).pipe( + Command.withHandler(({ paths }) => + Effect.sync(() => { + receivedPaths = paths; + }), + ), + ); + const testCommand = Command.make("test").pipe(Command.withSubcommands([db])); + const root = Command.make("supabase").pipe(Command.withSubcommands([testCommand])); + const args = ["test", "db", "--", "-foo.sql", "--literal"]; + + const exit = yield* Effect.exit( + Command.runWith(root, { version: "0.0.0-test" })(args).pipe( + Effect.provide( + Layer.mergeAll( + CliOutput.layer(textCliOutputFormatter()), + Layer.succeed(CliArgs, { args }), + mockOutput({ format: "text" }).layer, + emptyEnv(), + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ), ), ), - ), - ); + ); - expect(Exit.isSuccess(exit)).toBe(true); - expect(receivedPaths).toEqual(["-foo.sql", "--literal"]); - }); + expect(Exit.isSuccess(exit)).toBe(true); + expect(receivedPaths).toEqual(["-foo.sql", "--literal"]); + }), + ); }); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index dd640e968f..f88cc43170 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,7 +1,24 @@ import { BunServices } from "@effect/platform-bun"; -import { ProjectConfigStore } from "@supabase/config"; +import { ProjectConfigStore, ProjectEnvParseError } from "@supabase/config"; import { httpTransportClientLayer } from "@supabase/stack/effect"; -import { Cause, Console, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; +import { controlTransportLayer } from "@supabase/stack/managed"; +import { + Cause, + ConfigProvider, + Console, + Crypto, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Path, + Runtime, + Stdio, +} from "effect"; +import type { ConfigError } from "effect/Config"; +import type { SourceError } from "effect/ConfigProvider"; +import type * as PlatformError from "effect/PlatformError"; import { CliError, CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; import { Credentials } from "../../next/auth/credentials.service.ts"; @@ -13,7 +30,10 @@ import type { OutputFormat } from "../output/types.ts"; import { Output } from "../output/output.service.ts"; import { LegacyGoChildExitError } from "../legacy/legacy-go-child-exit.error.ts"; import { GoProxyInvocation, goProxyInvocationLayer } from "../legacy/go-proxy-invocation.ts"; +import { legacyViperEnvLayer } from "../legacy/legacy-viper-env.ts"; import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; +import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { ProjectContext } from "../../next/config/project-context.service.ts"; import { projectHomeLayer } from "../../next/config/project-home.layer.ts"; import { ProjectLocalServiceVersions } from "../../next/config/project-local-service-versions.service.ts"; import { projectContextLayer } from "../../next/config/project-context.layer.ts"; @@ -21,12 +41,16 @@ import { projectLinkStateLayer } from "../../next/config/project-link-state.laye import { processControlLayer } from "../runtime/process-control.layer.ts"; import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; import { ttyLayer } from "../runtime/tty.layer.ts"; +import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; +import { Tty } from "../runtime/tty.service.ts"; import { CommandRuntime } from "../runtime/command-runtime.service.ts"; import { ProcessControl } from "../runtime/process-control.service.ts"; import type { Analytics } from "../telemetry/analytics.service.ts"; import { aiToolLayer } from "../telemetry/ai-tool.layer.ts"; import { AiTool } from "../telemetry/ai-tool.service.ts"; import { telemetryRuntimeLayer } from "../telemetry/runtime.layer.ts"; +import { TelemetryRuntime } from "../telemetry/runtime.service.ts"; +import { TelemetryConfigError } from "../telemetry/consent.ts"; import { tracingLayer } from "../telemetry/tracing.layer.ts"; import { CliArgs } from "./cli-args.service.ts"; import { resolveAgentOutputFormatFromArgs } from "./agent-output.ts"; @@ -99,6 +123,15 @@ const globalFlagsWithValues = new Set([ // this list too, matching `db start`'s own precedent exactly. const selfManagedSignalCommands: ReadonlyArray<ReadonlyArray<string>> = [["functions", "serve"]]; +/** Provides project-home dependencies from an already-composed runtime layer. */ +const projectHomeFor = <E>( + runtimeServices: Layer.Layer< + FileSystem.FileSystem | Path.Path | RuntimeInfo | ProjectContext, + E, + never + >, +) => projectHomeLayer.pipe(Layer.provideMerge(runtimeServices)); + /** Positional command-path tokens from argv, skipping global flags and their values. */ export function extractCommandPath(args: ReadonlyArray<string>): ReadonlyArray<string> { const commandArgs: Array<string> = []; @@ -652,19 +685,29 @@ function cliConfigLayerFor(runtimeLayer: Layer.Layer<never>) { ); } -function projectHomeLayerFor(runtimeLayer: Layer.Layer<never>) { - return projectHomeLayer.pipe( - Layer.provide(cliConfigLayerFor(runtimeLayer)), - Layer.provide(projectContextLayerFor(runtimeLayer)), - Layer.provide(runtimeLayer), - Layer.provide(BunServices.layer), - ); -} - -type AnyAnalyticsLayer = Layer.Layer<Analytics, never, any>; +type AnalyticsLayer = Layer.Layer< + Analytics, + ConfigError | PlatformError.PlatformError | TelemetryConfigError, + | AiTool + | CliConfig + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | RuntimeInfo + | Tty + | TelemetryRuntime +>; + +type CliRuntimeError = + | CliError.CliError + | ConfigError + | PlatformError.PlatformError + | ProjectEnvParseError + | SourceError + | TelemetryConfigError; export interface RunCliOptions { - readonly analyticsLayer: AnyAnalyticsLayer; + readonly analyticsLayer: AnalyticsLayer; /** * Extra command paths (on top of the shared `selfManagedSignalCommands` list) that must NOT * be wrapped in the global signal-interrupt handler for this shell specifically — see @@ -691,13 +734,41 @@ export interface RunCliOptions { ) => Effect.Effect<void>; } -function cliProgramFor( - rootCommand: Command.Command.Any, +function makeCommandServices< + const Name extends string, + Input, + ContextInput, + RootError, + RootRequirements, +>( + rootCommand: Command.Command<Name, Input, ContextInput, RootError, RootRequirements>, args: ReadonlyArray<string>, options: RunCliOptions, - outputFormat: OutputFormat, ) { - const runtimeLayer = Layer.mergeAll(processControlLayer, runtimeInfoLayer, ttyLayer); + const configProviderLayer = ConfigProvider.layer( + ConfigProvider.fromEnv({ preserveEmptyStrings: true }), + ); + const runtimeLayer = Layer.mergeAll( + processControlLayer, + runtimeInfoLayer, + ttyLayer, + configProviderLayer, + ); + const projectContext = projectContextLayerFor(runtimeLayer); + const cliConfig = cliConfigLayerFor(runtimeLayer); + const runtimeServices = cliConfig.pipe( + Layer.provideMerge(projectContext), + Layer.provideMerge(runtimeLayer), + Layer.provideMerge(BunServices.layer), + ); + const projectHome = projectHomeFor(runtimeServices); + const telemetryRuntime = telemetryRuntimeLayer.pipe(Layer.provideMerge(runtimeServices)); + const resolvedAnalyticsLayer = options.analyticsLayer.pipe( + Layer.provideMerge(telemetryRuntime), + Layer.provideMerge(aiToolLayer), + ); + const tracing = tracingLayer.pipe(Layer.provideMerge(telemetryRuntime)); + const projectLinkState = projectLinkStateLayer.pipe(Layer.provideMerge(projectHome)); const fallbackCommandLayer = Layer.mergeAll( // Root command env inference currently leaks some subcommand-provided services. Layer.succeed(Credentials, { @@ -721,170 +792,222 @@ function cliProgramFor( }), ), ); - return withoutParseErrorHelpDump(Command.runWith(rootCommand, { version: CLI_VERSION })(args), { + return Layer.mergeAll( + resolvedAnalyticsLayer, + tracing, + projectLinkState, + legacyViperEnvLayer, + fallbackCommandLayer, + httpTransportClientLayer, + controlTransportLayer, + Layer.succeed(CliArgs, { args }), + BunServices.layer, + ); +} + +type CliCommandRequirements = Layer.Success<ReturnType<typeof makeCommandServices>>; + +function cliProgramFor<const Name extends string, Input, ContextInput, E>( + rootCommand: Command.Command<Name, Input, ContextInput, E, CliCommandRequirements>, + args: ReadonlyArray<string>, + options: RunCliOptions, + outputFormat: OutputFormat, +): Effect.Effect<void, CliRuntimeError | E, never> { + const commandServices = makeCommandServices(rootCommand, args, options); + const commandEnvironmentLayer: Layer.Layer<Command.Environment> = BunServices.layer; + const commandRun = Command.runWith(rootCommand.pipe(Command.provide(commandServices)), { + version: CLI_VERSION, + })(args).pipe( + Effect.provide( + Layer.mergeAll(formatterLayerFor(rootCommand, args, outputFormat), commandEnvironmentLayer), + ), + ); + const commandProgram: Effect.Effect<void, CliRuntimeError | E, never> = withoutParseErrorHelpDump< + void, + CliRuntimeError | E, + never + >(commandRun, { rootCommand, args, - }).pipe( - Effect.provide(formatterLayerFor(rootCommand, args, outputFormat)), - Effect.provide(options.analyticsLayer), - Effect.provide(tracingLayer), - Effect.provide(telemetryRuntimeLayer), - Effect.provide(cliConfigLayerFor(runtimeLayer)), - Effect.provide(projectHomeLayerFor(runtimeLayer)), - Effect.provide(projectContextLayerFor(runtimeLayer)), - Effect.provide(projectLinkStateLayer), - Effect.provide(runtimeLayer), - Effect.provide(httpTransportClientLayer), - Effect.provide(fallbackCommandLayer), - Effect.provide(Layer.succeed(CliArgs, { args })), - Effect.provide(BunServices.layer), - ); + }); + return commandProgram; } -export async function runCli(rootCommand: Command.Command.Any, options: RunCliOptions) { - const args = await Effect.runPromise( - Effect.gen(function* () { +function runCliEffect<const Name extends string, Input, ContextInput, E>( + rootCommand: Command.Command<Name, Input, ContextInput, E, CliCommandRequirements>, + options: RunCliOptions, +): Effect.Effect<never, CliRuntimeError | E, never> { + return Effect.gen(function* () { + const args = yield* Effect.gen(function* () { const stdio = yield* Stdio.Stdio; return yield* stdio.args; - }).pipe(Effect.provide(BunServices.layer)), - ); - - // Same `{ rootCommand, args }` shape `formatterLayerFor` builds below, so - // `normalizeCause`'s single-render fallback path (CLI-1901) can reuse - // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the - // text/json formatters would have shown before the vendored library's own - // duplicate render was suppressed. - const suggestionContext = { rootCommand, args }; - const useGlobalSignalInterrupt = shouldUseGlobalSignalInterrupt( - args, - options.additionalSelfManagedSignalCommands, - ); - const outputFormat = await Effect.runPromise( - Effect.gen(function* () { + }).pipe(Effect.provide(BunServices.layer)); + + // Same `{ rootCommand, args }` shape `formatterLayerFor` builds below, so + // `normalizeCause`'s single-render fallback path (CLI-1901) can reuse + // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the + // text/json formatters would have shown before the vendored library's own + // duplicate render was suppressed. + const suggestionContext = { rootCommand, args }; + const useGlobalSignalInterrupt = shouldUseGlobalSignalInterrupt( + args, + options.additionalSelfManagedSignalCommands, + ); + const outputFormat = yield* Effect.gen(function* () { const aiTool = yield* AiTool; return resolveAgentOutputFormatFromArgs(args, aiTool.name); - }).pipe(Effect.provide(aiToolLayer)), - ); - const cliProgram = cliProgramFor(rootCommand, args, options, outputFormat); - - const signalAwareProgram = Effect.scoped( - Effect.gen(function* () { - const processControl = yield* ProcessControl; - yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); - const cliFiber = yield* cliProgram.pipe(Effect.forkScoped); - const outcome = yield* Effect.raceFirst( - Fiber.await(cliFiber).pipe(Effect.map((exit) => ({ _tag: "cli" as const, exit }))), - processControl - .awaitSignal() - .pipe(Effect.map((signal) => ({ _tag: "signal" as const, signal }))), - ); - - if (outcome._tag === "signal") { - // SIGHUP must also stay held once cleanup begins. - yield* Effect.scoped( - processControl.holdSignals(["SIGHUP"]).pipe(Effect.andThen(Fiber.interrupt(cliFiber))), + }).pipe(Effect.provide(aiToolLayer)); + const cliProgram = cliProgramFor(rootCommand, args, options, outputFormat); + + const signalAwareProgram = Effect.scoped( + Effect.gen(function* () { + const processControl = yield* ProcessControl; + yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); + const cliFiber = yield* cliProgram.pipe(Effect.forkScoped); + const outcome = yield* Effect.raceFirst( + Fiber.await(cliFiber).pipe(Effect.map((exit) => ({ _tag: "cli" as const, exit }))), + processControl + .awaitSignal() + .pipe(Effect.map((signal) => ({ _tag: "signal" as const, signal }))), ); - return yield* Effect.interrupt; - } - return yield* outcome.exit; - }), - ).pipe( - Effect.provide(processControlLayer), - Effect.provide(runtimeInfoLayer), - Effect.provide(ttyLayer), - Effect.provide(httpTransportClientLayer), - Effect.provide(BunServices.layer), - ); + if (outcome._tag === "signal") { + // SIGHUP must also stay held once cleanup begins. + yield* Effect.scoped( + processControl.holdSignals(["SIGHUP"]).pipe(Effect.andThen(Fiber.interrupt(cliFiber))), + ); + return yield* Effect.interrupt; + } - const selfManagedSignalProgram = Effect.scoped( - Effect.gen(function* () { - const processControl = yield* ProcessControl; - yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); - return yield* cliProgram; - }), - ).pipe(Effect.provide(processControlLayer)); - - const handledRuntimeLayer = Layer.mergeAll(processControlLayer, runtimeInfoLayer, ttyLayer); - - const handledProgram = <A, E, R>( - program: Effect.Effect<A, E, R>, - ): Effect.Effect<never, unknown, never> => - Effect.gen(function* () { - const processControl = yield* ProcessControl; - const goProxyInvocation = yield* GoProxyInvocation; - const output = yield* Output; - const successTrailer = yield* SuccessTrailer; - const exit = yield* program.pipe(Effect.exit); - const afterSuccessHook = options.afterSuccess; - const afterSuccess = (code: number, cleanShowHelp: boolean) => - code === 0 - ? Effect.gen(function* () { - const trailers = yield* successTrailer.takeAll; - if (afterSuccessHook !== undefined || trailers.length > 0) { - yield* Effect.scoped( - processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]).pipe( - Effect.andThen( - Effect.gen(function* () { - if (afterSuccessHook !== undefined) { - const delegatedToGo = yield* goProxyInvocation.wasDelegated; - const workingDirectory = yield* successTrailer.workingDirectory; - yield* afterSuccessHook(args, { - cleanShowHelp, - delegatedToGo, - workingDirectory, - isValueTakingFlagToken: valueTakingFlagTokenPredicateForArgv( - rootCommand, - args, - ), - }); - } + return yield* outcome.exit; + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + processControlLayer, + runtimeInfoLayer, + ttyLayer, + httpTransportClientLayer, + BunServices.layer, + ), + ), + ); + + const selfManagedSignalProgram = Effect.scoped( + Effect.gen(function* () { + const processControl = yield* ProcessControl; + yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); + return yield* cliProgram; + }), + ).pipe(Effect.provide(processControlLayer)); + + const handledRuntimeLayer = Layer.mergeAll( + processControlLayer, + runtimeInfoLayer, + ttyLayer, + ConfigProvider.layer(ConfigProvider.fromEnv({ preserveEmptyStrings: true })), + ); + const handledProjectContext = projectContextLayerFor(handledRuntimeLayer); + const handledCliConfig = cliConfigLayerFor(handledRuntimeLayer); + const handledRuntimeServices = handledCliConfig.pipe( + Layer.provideMerge(handledProjectContext), + Layer.provideMerge(handledRuntimeLayer), + Layer.provideMerge(BunServices.layer), + ); + const handledProjectHome = projectHomeFor(handledRuntimeServices); + const handledTelemetryRuntime = telemetryRuntimeLayer.pipe( + Layer.provideMerge(handledRuntimeServices), + ); + const handledOutput = outputLayerFor(outputFormat).pipe( + Layer.provideMerge(handledRuntimeServices), + ); + const handledServices = Layer.mergeAll( + handledOutput, + handledTelemetryRuntime, + handledProjectHome, + httpTransportClientLayer, + goProxyInvocationLayer, + successTrailerLayer, + ); - yield* Effect.forEach(trailers, (text) => output.raw(text, "stderr"), { - discard: true, - }); - }), + const handledProgram = <A, ProgramError>( + program: Effect.Effect<A, ProgramError, never>, + ): Effect.Effect<never, CliRuntimeError | ProgramError, never> => + Effect.gen(function* () { + const processControl = yield* ProcessControl; + const goProxyInvocation = yield* GoProxyInvocation; + const output = yield* Output; + const successTrailer = yield* SuccessTrailer; + const exit = yield* program.pipe(Effect.exit); + const afterSuccessHook = options.afterSuccess; + const afterSuccess = (code: number, cleanShowHelp: boolean) => + code === 0 + ? Effect.gen(function* () { + const trailers = yield* successTrailer.takeAll; + if (afterSuccessHook !== undefined || trailers.length > 0) { + yield* Effect.scoped( + processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]).pipe( + Effect.andThen( + Effect.gen(function* () { + if (afterSuccessHook !== undefined) { + const delegatedToGo = yield* goProxyInvocation.wasDelegated; + const workingDirectory = yield* successTrailer.workingDirectory; + yield* afterSuccessHook(args, { + cleanShowHelp, + delegatedToGo, + workingDirectory, + isValueTakingFlagToken: valueTakingFlagTokenPredicateForArgv( + rootCommand, + args, + ), + }); + } + + yield* Effect.forEach(trailers, (text) => output.raw(text, "stderr"), { + discard: true, + }); + }), + ), ), - ), - ); - } - }) - : Effect.void; - if (Exit.isFailure(exit)) { - const exitCode = exitCodeForFailure(exit.cause); - // See `shouldReportFailure` for the reporting rules (and why they're - // NOT keyed on Effect's shared `[Runtime.errorReported]` marker). - // Literal `--help` never reaches this branch — it's handled as a - // successful `GlobalFlag.Action` and exits 0 via the success path - // below. See `exitCodeForFailure` for why a "clean" ShowHelp failure - // (e.g. a bare group command with no subcommand) also maps to exit 0. - if (shouldReportFailure(exit.cause, exitCode)) { - yield* output.fail(normalizeCause(exit.cause, suggestionContext)); + ); + } + }) + : Effect.void; + if (Exit.isFailure(exit)) { + const exitCode = exitCodeForFailure(exit.cause); + // See `shouldReportFailure` for the reporting rules (and why they're + // NOT keyed on Effect's shared `[Runtime.errorReported]` marker). + // Literal `--help` never reaches this branch — it's handled as a + // successful `GlobalFlag.Action` and exits 0 via the success path + // below. See `exitCodeForFailure` for why a "clean" ShowHelp failure + // (e.g. a bare group command with no subcommand) also maps to exit 0. + if (shouldReportFailure(exit.cause, exitCode)) { + yield* output.fail(normalizeCause(exit.cause, suggestionContext)); + } + yield* afterSuccess(exitCode, true); + return yield* processControl.exit(exitCode); } - yield* afterSuccess(exitCode, true); - return yield* processControl.exit(exitCode); - } - const exitCode = yield* processControl.getExitCode; - yield* afterSuccess(exitCode ?? 0, false); - return yield* processControl.exit(exitCode ?? 0); - }).pipe( - Effect.provide(outputLayerFor(outputFormat)), - Effect.provide(telemetryRuntimeLayer), - Effect.provide(projectHomeLayerFor(handledRuntimeLayer)), - Effect.provide(cliConfigLayerFor(handledRuntimeLayer)), - Effect.provide(projectContextLayerFor(handledRuntimeLayer)), - Effect.provide(processControlLayer), - Effect.provide(runtimeInfoLayer), - Effect.provide(ttyLayer), - Effect.provide(httpTransportClientLayer), - Effect.provide(BunServices.layer), - Effect.provide(goProxyInvocationLayer), - Effect.provide(successTrailerLayer), - ); + const exitCode = yield* processControl.getExitCode; + yield* afterSuccess(exitCode ?? 0, false); + return yield* processControl.exit(exitCode ?? 0); + }).pipe(Effect.provide(handledServices)); + + if (useGlobalSignalInterrupt) { + return yield* handledProgram(signalAwareProgram); + } else { + return yield* handledProgram(selfManagedSignalProgram); + } + }); +} - if (useGlobalSignalInterrupt) { - await Effect.runPromise(handledProgram(signalAwareProgram)); - } else { - await Effect.runPromise(handledProgram(selfManagedSignalProgram)); - } +/** + * Promise facade for the published CLI executable. Internal callers should + * compose `runCliEffect` so command failures and service requirements remain + * in Effect's typed channels until this outer package boundary. + */ +export function runCli<const Name extends string, Input, ContextInput, E>( + rootCommand: Command.Command<Name, Input, ContextInput, E, CliCommandRequirements>, + options: RunCliOptions, +): Promise<never> { + return Effect.runPromise(runCliEffect(rootCommand, options)); } diff --git a/apps/cli/src/shared/cli/version.integration.test.ts b/apps/cli/src/shared/cli/version.integration.test.ts index 72ae62c467..6a79487694 100644 --- a/apps/cli/src/shared/cli/version.integration.test.ts +++ b/apps/cli/src/shared/cli/version.integration.test.ts @@ -1,3 +1,6 @@ +// oxlint-disable effecttsgo/async-function -- these tests exercise the Promise-based CLI command boundary. +// oxlint-disable effecttsgo/unsafe-effect-type-assertion -- --version exits before other command services are evaluated. +// oxlint-disable typescript/no-base-to-string -- the console spy mirrors console.log's arbitrary argument formatting. import { describe, expect, test } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index 2824d04e24..b5c625b932 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { Path } from "effect"; /** * Resolves the global Supabase CLI state root. @@ -18,11 +18,12 @@ import { join } from "node:path"; * CLI never relies on them. */ export const resolveSupabaseHome = ( - env: Readonly<Record<string, string | undefined>>, + path: Path.Path, + configuredHome: string | undefined, homeDir: string, ): string => { - const configured = env["SUPABASE_HOME"]?.trim(); + const configured = configuredHome?.trim(); return configured !== undefined && configured.length > 0 ? configured - : join(homeDir, ".supabase"); + : path.join(homeDir, ".supabase"); }; diff --git a/apps/cli/src/shared/config/supabase-home.unit.test.ts b/apps/cli/src/shared/config/supabase-home.unit.test.ts index 2302e42cf3..03003ffa4b 100644 --- a/apps/cli/src/shared/config/supabase-home.unit.test.ts +++ b/apps/cli/src/shared/config/supabase-home.unit.test.ts @@ -1,31 +1,29 @@ -import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "vitest"; +import { Effect, Path } from "effect"; import { resolveSupabaseHome } from "./supabase-home.ts"; -const HOME = join("/home", "test"); +const testPath = Effect.runSync(Path.Path.pipe(Effect.provide(BunServices.layer))); +const HOME = testPath.join("/home", "test"); describe("resolveSupabaseHome", () => { it("returns SUPABASE_HOME when set to a non-empty value", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: "/custom/supabase" }, HOME)).toBe( - "/custom/supabase", - ); + expect(resolveSupabaseHome(testPath, "/custom/supabase", HOME)).toBe("/custom/supabase"); }); it("trims surrounding whitespace from SUPABASE_HOME", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: " /custom/supabase " }, HOME)).toBe( - "/custom/supabase", - ); + expect(resolveSupabaseHome(testPath, " /custom/supabase ", HOME)).toBe("/custom/supabase"); }); it("falls back to <homeDir>/.supabase when SUPABASE_HOME is unset", () => { - expect(resolveSupabaseHome({}, HOME)).toBe(join(HOME, ".supabase")); + expect(resolveSupabaseHome(testPath, undefined, HOME)).toBe(testPath.join(HOME, ".supabase")); }); it("falls back to <homeDir>/.supabase when SUPABASE_HOME is empty", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: "" }, HOME)).toBe(join(HOME, ".supabase")); + expect(resolveSupabaseHome(testPath, "", HOME)).toBe(testPath.join(HOME, ".supabase")); }); it("falls back to <homeDir>/.supabase when SUPABASE_HOME is whitespace only", () => { - expect(resolveSupabaseHome({ SUPABASE_HOME: " " }, HOME)).toBe(join(HOME, ".supabase")); + expect(resolveSupabaseHome(testPath, " ", HOME)).toBe(testPath.join(HOME, ".supabase")); }); }); diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index fe71911ef9..c584fd09b0 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -42,72 +42,69 @@ function validateSlug(slug: string): Effect.Effect<void, InvalidFunctionSlugErro return Effect.fail(new InvalidFunctionSlugError({ message: invalidFunctionSlugDetail })); } -export function deleteFunction<ResolveError, ResolveRequirements>( +export const deleteFunction = Effect.fn("functions.delete")(function* < + ResolveError, + ResolveRequirements, +>( flags: DeleteFunctionOptions, dependencies: DeleteFunctionDependencies<ResolveError, ResolveRequirements>, ) { - return Effect.gen(function* () { - const output = yield* Output; + const output = yield* Output; - yield* validateSlug(flags.slug); - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + yield* validateSlug(flags.slug); + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - const response = yield* dependencies.api - .executeRaw(operationDefinitions.v1DeleteAFunction, { - ref: projectRef, - function_slug: flags.slug, - }) - .pipe( - Effect.mapError((error) => { - if (error instanceof SupabaseApiInputError) { - // This operation's complete input is the resolved ref and the - // prevalidated slug, so a schema rejection is user-derived. - return markSupabaseApiInputErrorAsUserInput(error); - } - if (HttpClientError.isHttpClientError(error)) { - const description = error.reason.description ?? error.reason._tag; - return new DeleteFunctionNetworkError({ - message: `failed to delete function: ${description}`, - }); - } + const response = yield* dependencies.api + .executeRaw(operationDefinitions.v1DeleteAFunction, { + ref: projectRef, + function_slug: flags.slug, + }) + .pipe( + Effect.mapError((error) => { + if (error instanceof SupabaseApiInputError) { + // This operation's complete input is the resolved ref and the + // prevalidated slug, so a schema rejection is user-derived. + return markSupabaseApiInputErrorAsUserInput(error); + } + if (HttpClientError.isHttpClientError(error)) { + const description = error.reason.description ?? error.reason._tag; return new DeleteFunctionNetworkError({ - message: `failed to delete function: ${String(error)}`, + message: `failed to delete function: ${description}`, }); - }), - ); + } + return new DeleteFunctionNetworkError({ + message: `failed to delete function: ${String(error)}`, + }); + }), + ); - switch (response.status) { - case 200: - break; - case 404: - return yield* Effect.fail( - new FunctionNotFoundError({ - message: `Function ${flags.slug} does not exist on the Supabase project: nothing to delete`, - }), - ); - default: { - const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new DeleteFunctionUnexpectedStatusError({ - status: response.status, - message: `unexpected delete function status ${response.status}: ${body}`, - }), - ); - } - } - - if (output.format !== "text") { - yield* output.success("Deleted Edge Function.", { - function_slug: flags.slug, - project_ref: projectRef, + switch (response.status) { + case 200: + break; + case 404: + return yield* new FunctionNotFoundError({ + message: `Function ${flags.slug} does not exist on the Supabase project: nothing to delete`, + }); + default: { + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* new DeleteFunctionUnexpectedStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, }); - return; } + } - // Go: `fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), - // utils.Aqua(projectRef))` (`internal/functions/delete/delete.go:20`) — the - // legacy handler injects the aqua styling via `styleIdentifier`; next stays plain. - const style = dependencies.styleIdentifier ?? ((text: string) => text); - yield* output.raw(`Deleted Function ${style(flags.slug)} from project ${style(projectRef)}.\n`); - }).pipe(Effect.withSpan("functions.delete")); -} + if (output.format !== "text") { + yield* output.success("Deleted Edge Function.", { + function_slug: flags.slug, + project_ref: projectRef, + }); + return; + } + + // Go: `fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), + // utils.Aqua(projectRef))` (`internal/functions/delete/delete.go:20`) — the + // legacy handler injects the aqua styling via `styleIdentifier`; next stays plain. + const style = dependencies.styleIdentifier ?? ((text: string) => text); + yield* output.raw(`Deleted Function ${style(flags.slug)} from project ${style(projectRef)}.\n`); +}); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 636128febd..6effbcd590 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1,7 +1,6 @@ import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; -import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { URL } from "node:url"; +import { BunPath } from "@effect/platform-bun"; import { FunctionResponse, operationDefinitions, @@ -12,7 +11,8 @@ import { inferFunctionsManifest, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config"; -import { Duration, Effect, Option, Schema } from "effect"; +import { Clock, Config, Duration, Effect, FileSystem, Option, Predicate, Schema } from "effect"; +import * as EffectPath from "effect/Path"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; @@ -54,7 +54,38 @@ import { toSlash, } from "./functions-docker.ts"; import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; -import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; +import { + FunctionsApiStatusError, + FunctionsApiTransportError, + FunctionsOperationError, +} from "./functions-api.errors.ts"; + +const { basename, dirname, isAbsolute, join, relative, resolve, sep } = Effect.runSync( + EffectPath.Path.pipe(Effect.provide(BunPath.layer)), +); + +type DeployError = FunctionsOperationError | FunctionImportNotDirectoryError; +type DeployFsEffect<A> = Effect.Effect<A, DeployError, FileSystem.FileSystem>; + +function toFunctionsOperationError(operation: string, cause: unknown): FunctionsOperationError { + const code = getNestedErrorProperty(cause, "code"); + return cause instanceof FunctionsOperationError + ? cause + : new FunctionsOperationError({ + message: `${operation}: ${ + code === "ENOTDIR" + ? "ENOTDIR: not a directory" + : code === "EISDIR" + ? "EISDIR: illegal operation on a directory" + : code === "EACCES" + ? "EACCES: permission denied" + : cause instanceof Error + ? cause.message + : String(cause) + }`, + cause, + }); +} const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DEPLOY_RATE_LIMIT_MAX_RETRIES = 8; @@ -174,6 +205,8 @@ const decodeFunctionListResponseSchema = Schema.decodeUnknownSync(Schema.Array(F const decodeDeployFunctionResponseSchema = Schema.decodeUnknownSync( operationDefinitions.v1DeployAFunction.outputSchema, ); +const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJsonText = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); function omitNullableFields(value: unknown, fields: ReadonlySet<string>) { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -205,7 +238,7 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray<RemoteFunctio // eagerly JSON-decoded before the status check). function formatUnexpectedStatusBody(text: string): string { try { - return JSON.stringify(JSON.parse(text)); + return encodeJsonText(decodeJsonText(text)); } catch { return text; } @@ -332,7 +365,7 @@ export function dockerBindContainerPath(bind: string) { return separatorIndex === -1 ? "" : withoutMode.slice(separatorIndex + 1); } -function dockerNpmEnv(env: NodeJS.ProcessEnv = process.env): ReadonlyArray<string> { +function dockerNpmEnv(env: Readonly<Record<string, string | undefined>>): ReadonlyArray<string> { return dockerNpmEnvNames.flatMap((name) => { const value = env[name]; return value === undefined || value === "" ? [] : [name]; @@ -372,25 +405,27 @@ function hasParentPathSegment(relativePath: string) { .some((segment) => segment === ".."); } -async function realpathIfExists(pathname: string) { - try { - return await realpath(resolve(pathname)); - } catch (error) { - // ENOTDIR (a path routed through a file) is as nonexistent as ENOENT here. - if ( - error instanceof Error && - "code" in error && - (error.code === "ENOENT" || error.code === "ENOTDIR") - ) { - return resolve(pathname); - } - throw error; - } -} +const realpathIfExists: (pathname: string) => DeployFsEffect<string> = Effect.fnUntraced(function* ( + pathname: string, +) { + const fs = yield* FileSystem.FileSystem; + const resolved = resolve(pathname); + return yield* fs.realPath(resolved).pipe( + Effect.catch((error) => { + // ENOTDIR (a path routed through a file) is as nonexistent as ENOENT here. + const tag = getNestedErrorProperty(error, "_tag"); + return tag === "NotFound" || errorContainsText(error, "ENOTDIR") + ? Effect.succeed(resolved) + : Effect.fail(toFunctionsOperationError(`failed to resolve ${pathname}`, error)); + }), + ); +}); -async function resolveFunctionsSourceRoot(projectRoot: string) { - return (await findGitRootPath(projectRoot)) ?? resolve(projectRoot); -} +const resolveFunctionsSourceRoot: (projectRoot: string) => DeployFsEffect<string> = + Effect.fnUntraced(function* (projectRoot: string) { + const root = yield* findGitRootPath(projectRoot).pipe(Effect.provide(BunPath.layer)); + return root ?? resolve(projectRoot); + }); function humanSize(bytes: number) { if (bytes < 1000) { @@ -519,8 +554,50 @@ function isRemoteImportTarget(target: string) { } } -function getObjectProperty(input: object, key: string): unknown { - return Reflect.get(input, key); +function getObjectProperty(input: unknown, key: string): unknown { + return typeof input === "object" && input !== null ? Reflect.get(input, key) : undefined; +} + +function getNestedErrorProperty(input: unknown, key: string): unknown { + const seen = new Set<unknown>(); + const visit = (value: unknown): unknown => { + if (typeof value !== "object" || value === null || seen.has(value)) { + return undefined; + } + seen.add(value); + const property = getObjectProperty(value, key); + const nested = + visit(getObjectProperty(value, "reason")) ?? visit(getObjectProperty(value, "cause")); + if ( + property !== undefined && + !(key === "_tag" && (property === "FunctionsOperationError" || property === "PlatformError")) + ) { + return property; + } + return nested ?? property; + }; + return visit(input); +} + +function errorContainsText(input: unknown, fragment: string): boolean { + const seen = new Set<unknown>(); + const visit = (value: unknown): boolean => { + if (typeof value === "string") { + return value.includes(fragment); + } + if (typeof value !== "object" || value === null || seen.has(value)) { + return false; + } + seen.add(value); + return ( + visit(getObjectProperty(value, "message")) || + visit(getObjectProperty(value, "description")) || + visit(getObjectProperty(value, "code")) || + visit(getObjectProperty(value, "cause")) || + visit(getObjectProperty(value, "reason")) + ); + }; + return visit(input); } function readStringMap(input: unknown, fieldName: string): Record<string, string> { @@ -614,28 +691,52 @@ class ImportMapFile { } } -async function loadImportMapFile( +function parseImportMap( + pathname: string, + input: unknown, +): Effect.Effect<ImportMapFile, FunctionsOperationError> { + return Effect.try({ + try: () => ImportMapFile.fromUnknown(input), + catch: (error) => toFunctionsOperationError(`failed to parse ${pathname}`, error), + }); +} + +const loadImportMapFile: ( + pathname: string, + onRead?: (pathname: string, contents: Uint8Array) => DeployFsEffect<void>, + seen?: Set<string>, +) => DeployFsEffect<ImportMapFile> = Effect.fnUntraced(function* ( pathname: string, - onRead?: (pathname: string, contents: Uint8Array) => Promise<void>, + onRead?: (pathname: string, contents: Uint8Array) => DeployFsEffect<void>, seen = new Set<string>(), -): Promise<ImportMapFile> { +) { + const fs = yield* FileSystem.FileSystem; const resolvedPath = resolve(pathname); if (seen.has(resolvedPath)) { - throw new Error(`cyclic import map reference: ${pathname}`); + return yield* new FunctionsOperationError({ + message: `cyclic import map reference: ${pathname}`, + }); } seen.add(resolvedPath); - const contents = await readFile(pathname); + const contents = yield* fs + .readFile(pathname) + .pipe( + Effect.mapError((error) => toFunctionsOperationError(`failed to read ${pathname}`, error)), + ); if (onRead !== undefined) { - await onRead(pathname, contents); + yield* onRead(pathname, contents); } - const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); - const importMap = ImportMapFile.fromUnknown(parsed).resolve(toSlash(pathname)); + const parsed = yield* Effect.try({ + try: () => decodeJsonText(stripJsonComments(new TextDecoder().decode(contents))), + catch: (error) => toFunctionsOperationError(`failed to parse ${pathname}`, error), + }); + const importMap = (yield* parseImportMap(pathname, parsed)).resolve(toSlash(pathname)); if (isDenoConfigFile(pathname) && importMap.isReference()) { const nestedPath = join(dirname(pathname), importMap.importMapReference); - return loadImportMapFile(nestedPath, onRead, seen); + return yield* loadImportMapFile(nestedPath, onRead, seen); } return importMap; -} +}); function substituteImportMapValue( mappings: Readonly<Record<string, string>>, @@ -712,14 +813,22 @@ function resolveImportSpecifier( return { path: resolved, substituted }; } -async function walkImportPaths( +const walkImportPaths: ( + importMap: ImportMapFile, + srcPath: string, + allowedRoots: ReadonlyArray<string>, + displayRoot: string, + onFile: (pathname: string, contents: Uint8Array) => DeployFsEffect<void>, + onWarning: (message: string) => DeployFsEffect<void>, +) => DeployFsEffect<void> = Effect.fnUntraced(function* ( importMap: ImportMapFile, srcPath: string, allowedRoots: ReadonlyArray<string>, displayRoot: string, - onFile: (pathname: string, contents: Uint8Array) => Promise<void>, - onWarning: (message: string) => Promise<void>, + onFile: (pathname: string, contents: Uint8Array) => DeployFsEffect<void>, + onWarning: (message: string) => DeployFsEffect<void>, ) { + const fs = yield* FileSystem.FileSystem; const seen = new Set<string>(); const queue = [toSlash(srcPath)]; @@ -730,35 +839,49 @@ async function walkImportPaths( } seen.add(current); - let contents: Uint8Array; - try { - const resolvedCurrent = await realpath(resolve(current)); + const maybeContents = yield* Effect.gen(function* () { + const resolvedCurrent = yield* fs + .realPath(resolve(current)) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${current}`, error), + ), + ); if (!isContainedInAnyPath(allowedRoots, resolvedCurrent)) { - await onWarning(`WARN: Skipping import path outside source root: ${current}\n`); - continue; + yield* onWarning(`WARN: Skipping import path outside source root: ${current}\n`); + return Option.none<Uint8Array>(); } - contents = await readFile(resolvedCurrent); - } catch (error) { - if (error instanceof Error && "code" in error) { - if (error.code === "ENOENT") { + return Option.some( + yield* fs + .readFile(resolvedCurrent) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to read ${current}`, error), + ), + ), + ); + }).pipe( + Effect.catch((error) => { + const tag = getNestedErrorProperty(error, "_tag"); + if (tag === "NotFound") { const message = `failed to read file: open ${toApiRelativePath(displayRoot, current)}: no such file or directory`; - await onWarning(`WARN: ${message}\n`); - continue; + return onWarning(`WARN: ${message}\n`).pipe(Effect.as(Option.none<Uint8Array>())); } - // Go aborts on any other read error (pkg/function/deno.go:131-136); an - // ENOTDIR (import path routed through a file) gets Go's message instead - // of an unhandled raw Node error, via a classified error so telemetry - // books it as user-fixable config instead of a panic. - if (error.code === "ENOTDIR") { - throw new FunctionImportNotDirectoryError({ - message: `failed to read file: open ${toApiRelativePath(displayRoot, current)}: not a directory`, - }); + if (errorContainsText(error, "ENOTDIR")) { + return Effect.fail( + new FunctionImportNotDirectoryError({ + message: `failed to read file: open ${toApiRelativePath(displayRoot, current)}: not a directory`, + }), + ); } - } - throw error; + return Effect.fail(toFunctionsOperationError(`failed to read ${current}`, error)); + }), + ); + if (Option.isNone(maybeContents)) { + continue; } - - await onFile(current, contents); + const contents = maybeContents.value; + yield* onFile(current, contents); const text = new TextDecoder().decode(contents); importPathPattern.lastIndex = 0; for (const match of text.matchAll(importPathPattern)) { @@ -797,15 +920,15 @@ async function walkImportPaths( } const resolvedModule = resolve(modulePath); - const containmentPath = await realpathIfExists(resolvedModule); + const containmentPath = yield* realpathIfExists(resolvedModule); if (!isContainedInAnyPath(allowedRoots, containmentPath)) { - await onWarning(`WARN: Skipping import path outside source root: ${modulePath}\n`); + yield* onWarning(`WARN: Skipping import path outside source root: ${modulePath}\n`); continue; } queue.push(toSlash(resolvedModule)); } } -} +}); function hasGlobMeta(pattern: string) { return pattern.includes("*") || pattern.includes("?") || pattern.includes("["); @@ -874,131 +997,228 @@ function globBaseDirectory(pattern: string) { return stableParts.join("/"); } -async function listPathsRecursive(root: string): Promise<ReadonlyArray<string>> { - const resolvedRoot = resolve(root); - const entries = await readdir(resolvedRoot, { withFileTypes: true }); - const paths: string[] = []; - for (const entry of entries) { - const pathname = join(resolvedRoot, entry.name); - paths.push(pathname); - if (entry.isDirectory()) { - paths.push(...(await listPathsRecursive(pathname))); +const listPathsRecursive: (root: string) => DeployFsEffect<ReadonlyArray<string>> = + Effect.fnUntraced(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const resolvedRoot = resolve(root); + const entries = yield* fs + .readDirectory(resolvedRoot) + .pipe(Effect.mapError((error) => toFunctionsOperationError(`failed to read ${root}`, error))); + const paths: string[] = []; + for (const entry of entries) { + const pathname = join(resolvedRoot, entry); + const isSymlink = yield* fs.readLink(pathname).pipe( + Effect.map(() => true), + Effect.orElseSucceed(() => false), + ); + const info = yield* fs + .stat(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to stat ${pathname}`, error), + ), + ); + if (isSymlink && info.type === "Directory") { + continue; + } + paths.push(pathname); + if (!isSymlink && info.type === "Directory") { + paths.push(...(yield* listPathsRecursive(pathname))); + } } - } - return paths; -} + return paths; + }); -async function expandStaticPattern(pattern: string): Promise<ReadonlyArray<string>> { - if (!hasGlobMeta(pattern)) { - try { - await stat(pattern); - } catch { - throw new Error(`no files matched pattern: ${pattern}`); +const expandStaticPattern: (pattern: string) => DeployFsEffect<ReadonlyArray<string>> = + Effect.fnUntraced(function* (pattern: string) { + const fs = yield* FileSystem.FileSystem; + if (!hasGlobMeta(pattern)) { + const exists = yield* fs + .exists(pattern) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to inspect ${pattern}`, error), + ), + ); + if (!exists) { + return yield* new FunctionsOperationError({ + message: `no files matched pattern: ${pattern}`, + }); + } + return [pattern]; } - return [pattern]; - } - const baseDir = globBaseDirectory(pattern); - const matcher = globToRegExp(toSlash(resolve(pattern))); - let candidates: ReadonlyArray<string>; - try { - candidates = await listPathsRecursive(baseDir); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - throw new Error(`no files matched pattern: ${pattern}`); + const baseDir = globBaseDirectory(pattern); + const matcher = globToRegExp(toSlash(resolve(pattern))); + const candidates = yield* listPathsRecursive(baseDir).pipe( + Effect.catch((error) => + getNestedErrorProperty(error, "_tag") === "NotFound" + ? Effect.fail( + new FunctionsOperationError({ message: `no files matched pattern: ${pattern}` }), + ) + : Effect.fail(toFunctionsOperationError(`failed to expand ${pattern}`, error)), + ), + ); + const matches = candidates.filter((candidate) => matcher.test(toSlash(resolve(candidate)))); + if (matches.length === 0) { + return yield* new FunctionsOperationError({ + message: `no files matched pattern: ${pattern}`, + }); } - throw error; - } - const matches = candidates.filter((candidate) => matcher.test(toSlash(resolve(candidate)))); - if (matches.length === 0) { - throw new Error(`no files matched pattern: ${pattern}`); - } - return matches; -} + return matches; + }); -async function forEachLocalImportMapTarget( +const forEachLocalImportMapTarget: ( + importMap: ImportMapFile, + onTarget: (pathname: string) => DeployFsEffect<void>, +) => DeployFsEffect<void> = Effect.fnUntraced(function* ( importMap: ImportMapFile, - onTarget: (pathname: string) => Promise<void>, + onTarget: (pathname: string) => DeployFsEffect<void>, ) { for (const target of Object.values(importMap.imports)) { if (isRemoteImportTarget(target)) { continue; } - await onTarget(target); + yield* onTarget(target); } for (const scope of Object.values(importMap.scopes)) { for (const target of Object.values(scope)) { if (isRemoteImportTarget(target)) { continue; } - await onTarget(target); + yield* onTarget(target); } } -} +}); -async function walkLocalImportMapTargetImports( +const walkLocalImportMapTargetImports: ( importMap: ImportMapFile, pathname: string, allowedRoots: ReadonlyArray<string>, displayRoot: string, - onFile: (pathname: string, contents: Uint8Array) => Promise<void>, - onWarning: (message: string) => Promise<void>, + onFile: (pathname: string, contents: Uint8Array) => DeployFsEffect<void>, + onWarning: (message: string) => DeployFsEffect<void>, +) => DeployFsEffect<void> = Effect.fnUntraced(function* ( + importMap: ImportMapFile, + pathname: string, + allowedRoots: ReadonlyArray<string>, + displayRoot: string, + onFile: (pathname: string, contents: Uint8Array) => DeployFsEffect<void>, + onWarning: (message: string) => DeployFsEffect<void>, ) { - if ((await stat(pathname)).isDirectory()) { + const fs = yield* FileSystem.FileSystem; + if ( + (yield* fs + .stat(pathname) + .pipe( + Effect.mapError((error) => toFunctionsOperationError(`failed to stat ${pathname}`, error)), + )).type === "Directory" + ) { return; } - await walkImportPaths(importMap, pathname, allowedRoots, displayRoot, onFile, onWarning); -} + yield* walkImportPaths(importMap, pathname, allowedRoots, displayRoot, onFile, onWarning); +}); -async function isFile(pathname: string): Promise<boolean> { - try { - return (await stat(pathname)).isFile(); - } catch { - return false; - } -} +const isFile: (pathname: string) => DeployFsEffect<boolean> = Effect.fnUntraced(function* ( + pathname: string, +) { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(pathname).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); +}); -async function resolveImportMapAllowedRoots(projectRoot: string, importMapPath: string) { - const realProjectRoot = await realpath(projectRoot); +const resolveImportMapAllowedRoots: ( + projectRoot: string, + importMapPath: string, +) => DeployFsEffect<ReadonlyArray<string>> = Effect.fnUntraced(function* ( + projectRoot: string, + importMapPath: string, +) { + const fs = yield* FileSystem.FileSystem; + const realProjectRoot = yield* fs + .realPath(projectRoot) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${projectRoot}`, error), + ), + ); const allowedRoots = [realProjectRoot]; if (importMapPath.length === 0) { return allowedRoots; } - const realImportMapPath = await realpath(importMapPath); + const realImportMapPath = yield* fs + .realPath(importMapPath) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${importMapPath}`, error), + ), + ); if (!isContainedPath(realProjectRoot, realImportMapPath)) { allowedRoots.push(dirname(realImportMapPath)); } if (isDenoConfigFile(importMapPath)) { - const contents = await readFile(importMapPath); - const parsed = JSON.parse(stripJsonComments(new TextDecoder().decode(contents))); - const importMap = ImportMapFile.fromUnknown(parsed); - if (importMap.importMapReference.length > 0) { - const referencedImportMapPath = await realpath( - join(dirname(importMapPath), importMap.importMapReference), + const contents = yield* fs + .readFile(importMapPath) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to read ${importMapPath}`, error), + ), ); + const parsed = yield* Effect.try({ + try: () => decodeJsonText(stripJsonComments(new TextDecoder().decode(contents))), + catch: (error) => toFunctionsOperationError(`failed to parse ${importMapPath}`, error), + }); + const importMap = yield* parseImportMap(importMapPath, parsed); + if (importMap.importMapReference.length > 0) { + const referencedImportMapPath = yield* fs + .realPath(join(dirname(importMapPath), importMap.importMapReference)) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${importMap.importMapReference}`, error), + ), + ); if (!isContainedPath(realProjectRoot, referencedImportMapPath)) { allowedRoots.push(dirname(referencedImportMapPath)); } } } return allowedRoots; -} +}); -async function writeSourceDeployForm( +const writeSourceDeployForm: ( sourceRoot: string, workdir: string, config: ResolvedDeployFunctionConfig, metadata: SourceDeployMetadata, - outputRaw: (text: string) => Effect.Effect<void, never>, + outputRaw: (text: string) => DeployFsEffect<void>, +) => DeployFsEffect<FormData> = Effect.fnUntraced(function* ( + sourceRoot: string, + workdir: string, + config: ResolvedDeployFunctionConfig, + metadata: SourceDeployMetadata, + outputRaw: (text: string) => DeployFsEffect<void>, ) { + const fs = yield* FileSystem.FileSystem; const form = new FormData(); - form.append("metadata", JSON.stringify(metadata)); - const realSourceRoot = await realpath(sourceRoot); - const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); + form.append("metadata", encodeJsonText(metadata)); + const realSourceRoot = yield* fs + .realPath(sourceRoot) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${sourceRoot}`, error), + ), + ); + const importMapAllowedRoots = yield* resolveImportMapAllowedRoots(sourceRoot, config.importMap); const uploadedAssets = new Set<string>(); - const appendAsset = async (pathname: string, contents: Uint8Array, realPathname: string) => { + const appendAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + realPathname: string, + ) { if (uploadedAssets.has(realPathname)) { return; } @@ -1008,130 +1228,223 @@ async function writeSourceDeployForm( // NOT at `sourceRoot` — see the CLI-1985 note in `deployViaApi`. const relativePath = toApiRelativePath(workdir, pathname); if (hasParentPathSegment(relativePath)) { - throw new Error(`failed to read file: open ${relativePath}: invalid argument`); + return yield* new FunctionsOperationError({ + message: `failed to read file: open ${relativePath}: invalid argument`, + }); } - await Effect.runPromise(outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`)); + yield* outputRaw(`Uploading asset (${config.slug}): ${relativePath}\n`); form.append("file", new File([contents], relativePath)); - }; + }); - const uploadAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); + const uploadAsset = Effect.fnUntraced(function* (pathname: string, contents: Uint8Array) { + const realPathname = yield* fs + .realPath(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${pathname}`, error), + ), + ); if (!isContainedPath(realSourceRoot, realPathname)) { - throw new Error(`refusing to upload asset outside source root: ${pathname}`); + return yield* new FunctionsOperationError({ + message: `refusing to upload asset outside source root: ${pathname}`, + }); } - await appendAsset(pathname, contents, realPathname); - }; + yield* appendAsset(pathname, contents, realPathname); + }); - const uploadImportMapAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); + const uploadImportMapAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + ) { + const realPathname = yield* fs + .realPath(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${pathname}`, error), + ), + ); if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { - throw new Error(`refusing to upload import map outside allowed roots: ${pathname}`); + return yield* new FunctionsOperationError({ + message: `refusing to upload import map outside allowed roots: ${pathname}`, + }); } - await appendAsset(pathname, contents, realPathname); - }; + yield* appendAsset(pathname, contents, realPathname); + }); - const uploadImportMapTargetAsset = async (pathname: string, contents: Uint8Array) => { - const realPathname = await realpath(pathname); - if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), + const uploadImportMapTargetAsset = Effect.fnUntraced(function* ( + pathname: string, + contents: Uint8Array, + ) { + const realPathname = yield* fs + .realPath(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${pathname}`, error), + ), ); + if (!isContainedInAnyPath(importMapAllowedRoots, realPathname)) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`); return; } - await appendAsset(pathname, contents, realPathname); - }; + yield* appendAsset(pathname, contents, realPathname); + }); - const uploadScopeTarget = async (pathname: string) => { - let resolvedPath: string; - let pathInfo: Awaited<ReturnType<typeof stat>>; - try { - resolvedPath = await realpath(pathname); - pathInfo = await stat(pathname); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOTDIR") { - await Effect.runPromise( - outputRaw(`WARN: Skipping import map target that is not a directory: ${pathname}\n`), - ); + const uploadScopeTarget: (pathname: string) => DeployFsEffect<void> = Effect.fnUntraced( + function* (pathname: string) { + const pathResult = yield* Effect.gen(function* () { + return { + resolvedPath: yield* fs + .realPath(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${pathname}`, error), + ), + ), + pathInfo: yield* fs + .stat(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to stat ${pathname}`, error), + ), + ), + }; + }).pipe( + Effect.map(Option.some), + Effect.catch((error) => { + if (errorContainsText(error, "ENOTDIR")) { + return outputRaw( + `WARN: Skipping import map target that is not a directory: ${pathname}\n`, + ).pipe(Effect.as(Option.none())); + } + return Effect.fail(error); + }), + ); + if (Option.isNone(pathResult)) { return; } - throw error; - } - if (!isContainedInAnyPath(importMapAllowedRoots, resolvedPath)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`), - ); - return; - } - if (!pathInfo.isDirectory()) { - await uploadImportMapTargetAsset(pathname, await readFile(pathname)); - await walkLocalImportMapTargetImports( - importMap, - pathname, - importMapAllowedRoots, - workdir, - uploadImportMapTargetAsset, - async (message) => { - await Effect.runPromise(outputRaw(message)); - }, - ); - return; - } - const nestedPaths = await listPathsRecursive(pathname); - for (const nestedPath of nestedPaths) { - if ((await stat(nestedPath)).isDirectory()) { - continue; + const { resolvedPath, pathInfo } = pathResult.value; + if (!isContainedInAnyPath(importMapAllowedRoots, resolvedPath)) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${pathname}\n`); + return; } - const resolvedNestedPath = await realpath(nestedPath); - if (!isContainedInAnyPath(importMapAllowedRoots, resolvedNestedPath)) { - await Effect.runPromise( - outputRaw(`WARN: Skipping import path outside source root: ${nestedPath}\n`), + if (pathInfo.type !== "Directory") { + yield* uploadImportMapTargetAsset( + pathname, + yield* fs + .readFile(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to read ${pathname}`, error), + ), + ), ); - continue; + yield* walkLocalImportMapTargetImports( + importMap, + pathname, + importMapAllowedRoots, + workdir, + uploadImportMapTargetAsset, + (message) => outputRaw(message), + ); + return; } - await uploadImportMapTargetAsset(nestedPath, await readFile(nestedPath)); - } - }; + const nestedPaths = yield* listPathsRecursive(pathname); + for (const nestedPath of nestedPaths) { + if ( + (yield* fs + .stat(nestedPath) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to stat ${nestedPath}`, error), + ), + )).type === "Directory" + ) { + continue; + } + const resolvedNestedPath = yield* fs + .realPath(nestedPath) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${nestedPath}`, error), + ), + ); + if (!isContainedInAnyPath(importMapAllowedRoots, resolvedNestedPath)) { + yield* outputRaw(`WARN: Skipping import path outside source root: ${nestedPath}\n`); + continue; + } + yield* uploadImportMapTargetAsset( + nestedPath, + yield* fs + .readFile(nestedPath) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to read ${nestedPath}`, error), + ), + ), + ); + } + }, + ); if (metadata.import_map_path !== undefined && metadata.import_map_path.length > 0) { - await loadImportMapFile(config.importMap, uploadImportMapAsset); + yield* loadImportMapFile(config.importMap, uploadImportMapAsset); } for (const pattern of config.staticFiles) { - let files: ReadonlyArray<string>; - try { - files = await expandStaticPattern(pattern); - } catch (error) { - await Effect.runPromise( - outputRaw(`WARN: ${error instanceof Error ? error.message : String(error)}\n`), - ); + const files = yield* expandStaticPattern(pattern).pipe( + Effect.map(Option.some), + Effect.catch((error) => + outputRaw(`WARN: ${error instanceof Error ? error.message : String(error)}\n`).pipe( + Effect.as(Option.none()), + ), + ), + ); + if (Option.isNone(files)) { continue; } - for (const pathname of files) { - if ((await stat(pathname)).isDirectory()) { - throw new Error(`file path is a directory: ${pathname}`); + for (const pathname of files.value) { + if ( + (yield* fs + .stat(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to stat ${pathname}`, error), + ), + )).type === "Directory" + ) { + return yield* new FunctionsOperationError({ + message: `file path is a directory: ${pathname}`, + }); } - await uploadAsset(pathname, await readFile(pathname)); + yield* uploadAsset( + pathname, + yield* fs + .readFile(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to read ${pathname}`, error), + ), + ), + ); } } const importMap = metadata.import_map_path !== undefined && metadata.import_map_path.length > 0 - ? await loadImportMapFile(config.importMap) + ? yield* loadImportMapFile(config.importMap) : new ImportMapFile(); - await walkImportPaths( + yield* walkImportPaths( importMap, config.entrypoint, [realSourceRoot], workdir, uploadAsset, - async (message) => { - await Effect.runPromise(outputRaw(message)); - }, + (message) => outputRaw(message), ); - await forEachLocalImportMapTarget(importMap, uploadScopeTarget); + yield* forEachLocalImportMapTarget(importMap, uploadScopeTarget); return form; -} +}); /** * Server-recorded metadata paths are anchored at the workdir, matching Go's @@ -1199,39 +1512,52 @@ function sanitizeDockerBinds( return result; } -export async function buildDockerBinds( +export const buildDockerBinds: ( + projectId: string, + functionsDir: string, + outputDir: string, + config: ResolvedDeployFunctionConfig, + options?: { + readonly additionalModuleRoots?: ReadonlyArray<string>; + readonly onWarning?: (message: string) => DeployFsEffect<void>; + readonly skipMissingImportMapTargets?: boolean; + readonly bitbucketCloneDir?: string; + }, +) => DeployFsEffect<ReadonlyArray<string>> = Effect.fnUntraced(function* ( projectId: string, functionsDir: string, outputDir: string, config: ResolvedDeployFunctionConfig, options: { readonly additionalModuleRoots?: ReadonlyArray<string>; - readonly onWarning?: (message: string) => Promise<void>; + readonly onWarning?: (message: string) => DeployFsEffect<void>; readonly skipMissingImportMapTargets?: boolean; + readonly bitbucketCloneDir?: string; } = {}, ) { + const fs = yield* FileSystem.FileSystem; const hostFunctionsDir = resolve(functionsDir); const hostOutputDir = resolve(outputDir); const projectRoot = resolve(functionsDir, "..", ".."); - const sourceRoot = await resolveFunctionsSourceRoot(projectRoot); - const realSourceRoot = await realpath(sourceRoot); + const sourceRoot = yield* resolveFunctionsSourceRoot(projectRoot); + const realSourceRoot = yield* fs + .realPath(sourceRoot) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${sourceRoot}`, error), + ), + ); const moduleRoots = [ realSourceRoot, - ...( - await Promise.all( - (options.additionalModuleRoots ?? []).map(async (root) => { - try { - return await realpath(root); - } catch { - return undefined; - } - }), - ) - ).flatMap((root) => (root === undefined ? [] : [root])), + ...(yield* Effect.forEach( + options.additionalModuleRoots ?? [], + (root) => fs.realPath(root).pipe(Effect.option), + { concurrency: "unbounded" }, + )).flatMap((root) => (Option.isSome(root) ? [root.value] : [])), ]; - const importMapAllowedRoots = await resolveImportMapAllowedRoots(sourceRoot, config.importMap); + const importMapAllowedRoots = yield* resolveImportMapAllowedRoots(sourceRoot, config.importMap); const binds = [`${hostFunctionsDir}:${toDockerPath(hostFunctionsDir)}:ro`]; - if (process.env["BITBUCKET_CLONE_DIR"] === undefined) { + if (options.bitbucketCloneDir === undefined) { binds.unshift(`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`); } @@ -1240,99 +1566,120 @@ export async function buildDockerBinds( } const extraBinds: string[] = []; - const appendBindWithinRoots = async (roots: ReadonlyArray<string>, pathname: string) => { - const hostPath = await realpath(pathname); + const appendBindWithinRoots = Effect.fnUntraced(function* ( + roots: ReadonlyArray<string>, + pathname: string, + ) { + const hostPath = yield* fs + .realPath(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to resolve ${pathname}`, error), + ), + ); if (!isContainedInAnyPath(roots, hostPath)) { return; } extraBinds.push(`${hostPath}:${toDockerPath(hostPath)}:ro`); - }; - const appendProjectBind = async (pathname: string, _contents: Uint8Array) => + }); + const appendProjectBind = (pathname: string, _contents: Uint8Array) => appendBindWithinRoots([realSourceRoot], pathname); - const appendModuleBind = async (pathname: string, _contents: Uint8Array) => + const appendModuleBind = (pathname: string, _contents: Uint8Array) => appendBindWithinRoots(moduleRoots, pathname); - const appendImportMapBind = async (pathname: string, _contents: Uint8Array) => + const appendImportMapBind = (pathname: string, _contents: Uint8Array) => appendBindWithinRoots(importMapAllowedRoots, pathname); const importMap = config.importMap.length > 0 - ? await loadImportMapFile(config.importMap, appendImportMapBind) + ? yield* loadImportMapFile(config.importMap, appendImportMapBind) : new ImportMapFile(); - await walkImportPaths( + yield* walkImportPaths( importMap, config.entrypoint, moduleRoots, sourceRoot, appendModuleBind, - options.onWarning ?? (async () => {}), + options.onWarning ?? (() => Effect.void), ); - await forEachLocalImportMapTarget(importMap, async (target) => { - try { - await appendBindWithinRoots(importMapAllowedRoots, target); - if ((await stat(target)).isDirectory()) { + yield* forEachLocalImportMapTarget(importMap, (target) => + Effect.gen(function* () { + yield* appendBindWithinRoots(importMapAllowedRoots, target); + if ( + (yield* fs + .stat(target) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to stat ${target}`, error), + ), + )).type === "Directory" + ) { return; } - await walkLocalImportMapTargetImports( + yield* walkLocalImportMapTargetImports( importMap, target, importMapAllowedRoots, sourceRoot, appendImportMapBind, - async () => {}, + () => Effect.void, ); - } catch (error) { - if (error instanceof Error && "code" in error) { - // ENOTDIR (a trailing-slash value routed through a file) is never a - // walkable target regardless of caller: an import that actually - // reaches through that file still fails via the walker's - // FunctionImportNotDirectoryError. - if (error.code === "ENOTDIR") { - await (options.onWarning ?? (async () => {}))( + }).pipe( + Effect.catch((error) => { + const tag = getNestedErrorProperty(error, "_tag"); + if (errorContainsText(error, "ENOTDIR")) { + return (options.onWarning ?? (() => Effect.void))( `WARN: Skipping import map target that is not a directory: ${target}\n`, ); - return; } - if (options.skipMissingImportMapTargets === true && error.code === "ENOENT") { - await (options.onWarning ?? (async () => {}))( + if (options.skipMissingImportMapTargets === true && tag === "NotFound") { + return (options.onWarning ?? (() => Effect.void))( `WARN: Skipping missing import map target: ${target}\n`, ); - return; } - } - throw error; - } - }); + return Effect.fail(toFunctionsOperationError(`failed to inspect ${target}`, error)); + }), + ), + ); for (const pattern of config.staticFiles) { - let files: ReadonlyArray<string>; - try { - files = await expandStaticPattern(pattern); - } catch { + const files = yield* expandStaticPattern(pattern).pipe(Effect.option); + if (Option.isNone(files)) { continue; } - for (const pathname of files) { - if ((await stat(pathname)).isDirectory()) { - throw new Error(`file path is a directory: ${pathname}`); + for (const pathname of files.value) { + if ( + (yield* fs + .stat(pathname) + .pipe( + Effect.mapError((error) => + toFunctionsOperationError(`failed to stat ${pathname}`, error), + ), + )).type === "Directory" + ) { + return yield* new FunctionsOperationError({ + message: `file path is a directory: ${pathname}`, + }); } - await appendProjectBind(pathname, new Uint8Array()); + yield* appendProjectBind(pathname, new Uint8Array()); } } return [...binds, ...sanitizeDockerBinds(extraBinds, hostFunctionsDir, hostOutputDir)]; -} +}); function shouldUseDenoJsonDiscovery(entrypoint: string, importMap: string) { return isDenoConfigFile(importMap) && dirname(importMap) === dirname(entrypoint); } -async function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: string) { +function shouldUsePackageJsonDiscovery(entrypoint: string, importMap: string) { if (importMap.length > 0) { - return false; - } - try { - await stat(join(dirname(entrypoint), "package.json")); - return true; - } catch { - return false; + return Effect.succeed(false); } + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.stat(join(dirname(entrypoint), "package.json")).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + }); } interface BundleFunctionWithDockerOptions { @@ -1361,31 +1708,34 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( projectEnvValues, } = options; const output = yield* Output; + const fs = yield* FileSystem.FileSystem; + const debug = yield* Config.boolean("DEBUG").pipe(Effect.orElseSucceed(() => false)); // Go: `fmt.Fprintln(os.Stderr, "Bundling Function:", utils.Bold(slug))` // (`internal/functions/deploy/bundle.go:30`) — the legacy handler injects // the bold styling via `styleEmphasis`; next stays plain. yield* output.raw(`Bundling Function: ${styleEmphasis(config.slug)}\n`, "stderr"); const outputRoot = resolve(functionsDir, "..", ".temp"); - yield* Effect.tryPromise(() => mkdir(outputRoot, { recursive: true })); - const outputDir = yield* Effect.tryPromise(() => - mkdtemp(join(outputRoot, `.supabase-output-${config.slug}-`)), - ); + yield* fs.makeDirectory(outputRoot, { recursive: true }); + const outputDir = yield* fs.makeTempDirectory({ + directory: outputRoot, + prefix: `.supabase-output-${config.slug}-`, + }); try { // Go passes 0777 to MkdirAll, which Windows ignores. Calling chmod separately // adds an NTFS WRITE_ATTRIBUTES requirement that the Go CLI does not have. if (shouldChmodBundleOutputDirectory(process.platform)) { - yield* Effect.tryPromise({ - try: () => chmod(outputDir, 0o777), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + yield* fs.chmod(outputDir, 0o777); } const outputPath = join(outputDir, "output.eszip"); - const binds = yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, outputDir, config, { - onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), - }), - ); + const bitbucketCloneDir = + projectEnvValues === undefined + ? Option.getOrUndefined(yield* Config.option(Config.string("BITBUCKET_CLONE_DIR"))) + : yield* legacyViperEnvStringWithProjectFallback("BITBUCKET_CLONE_DIR", projectEnvValues); + const binds = yield* buildDockerBinds(projectId, functionsDir, outputDir, config, { + onWarning: (message) => output.raw(message, "stderr"), + bitbucketCloneDir, + }); // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) // — resolves ECR->GHCR->Docker-Hub candidates and pulls with retry, per // container, before ever touching the network/volume. Deliberately NOT @@ -1401,17 +1751,22 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( projectEnvValues, ); yield* ensureDockerNetwork(networkMode, projectId); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume( + localDockerId("edge_runtime", projectId), + projectId, + projectEnvValues, + ); const env: Array<string> = []; - if ( - !(yield* Effect.promise(() => - shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap), - )) - ) { + if (!(yield* shouldUsePackageJsonDiscovery(config.entrypoint, config.importMap))) { env.push("DENO_NO_PACKAGE_JSON=1"); } - env.push(...dockerNpmEnv()); + const npmConfigRegistry = yield* Config.option(Config.string("NPM_CONFIG_REGISTRY")); + env.push( + ...dockerNpmEnv({ + NPM_CONFIG_REGISTRY: Option.getOrUndefined(npmConfigRegistry), + }), + ); const containerArgs = [ "bundle", @@ -1429,7 +1784,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( for (const staticFile of config.staticFiles) { containerArgs.push("--static", toDockerPath(staticFile)); } - if (verbose || process.env["DEBUG"] === "true") { + if (verbose || debug) { containerArgs.push("--verbose"); } @@ -1457,16 +1812,20 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( onStderr: (chunk) => output.raw(chunk, "stderr"), }); if (result.exitCode !== 0) { - return yield* Effect.fail(new Error(`failed to bundle function: exit ${result.exitCode}`)); + return yield* new FunctionsOperationError({ + message: `failed to bundle function: exit ${result.exitCode}`, + }); } - const eszip = yield* Effect.tryPromise({ - try: () => readFile(outputPath), - catch: (error) => - new Error( - `failed to open eszip: ${error instanceof Error ? error.message : String(error)}`, - ), - }); + const eszip = yield* fs.readFile(outputPath).pipe( + Effect.mapError( + (error) => + new FunctionsOperationError({ + message: `failed to open eszip: ${error.message}`, + cause: error, + }), + ), + ); const compressed = new Uint8Array( Buffer.concat([ Buffer.from(COMPRESSED_ESZIP_MAGIC), @@ -1485,9 +1844,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( body: compressed, } satisfies BundledFunction; } finally { - yield* Effect.tryPromise(() => rm(outputDir, { recursive: true, force: true })).pipe( - Effect.orElseSucceed(() => undefined), - ); + yield* fs.remove(outputDir, { recursive: true, force: true }).pipe(Effect.ignore); } }); @@ -1513,7 +1870,7 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project // not a transport failure — surface it via FunctionsApiStatusError so it // classifies as api_status rather than network. return yield* Effect.try({ - try: () => decodeFunctionListResponse(JSON.parse(body)), + try: () => decodeFunctionListResponse(decodeJsonText(body)), catch: (error) => new FunctionsApiStatusError({ status: result.response.status, @@ -1537,24 +1894,26 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } } - return yield* Effect.fail(lastError ?? new Error("failed to list functions")); + return yield* Effect.fail( + lastError ?? new FunctionsOperationError({ message: "failed to list functions" }), + ); }); function headerValue(headers: Readonly<Record<string, string | undefined>>, name: string) { return headers[name.toLowerCase()] ?? headers[name]; } -function parseRateLimitDelay(value: string | undefined): number | undefined { +function parseRateLimitDelay(value: string | undefined, now: number): number | undefined { if (value === undefined || value.length === 0) { return undefined; } - const seconds = Number.parseInt(value, 10); - if (Number.isFinite(seconds)) { - return Math.max(seconds, 0) * 1_000; + const seconds = Number(value.trim()); + if (Number.isInteger(seconds) && seconds >= 0) { + return seconds * 1_000; } - const timestamp = Date.parse(value); - if (!Number.isNaN(timestamp)) { - return Math.max(timestamp - Date.now(), 0); + const parsedDate = Option.getOrUndefined(Schema.decodeOption(Schema.DateFromString)(value)); + if (parsedDate !== undefined && Number.isFinite(parsedDate.getTime())) { + return Math.max(parsedDate.getTime() - now, 0); } return undefined; } @@ -1562,10 +1921,11 @@ function parseRateLimitDelay(value: string | undefined): number | undefined { function rateLimitDelayMillis( headers: Readonly<Record<string, string | undefined>>, attempt: number, + now: number, ) { return ( - parseRateLimitDelay(headerValue(headers, "retry-after")) ?? - parseRateLimitDelay(headerValue(headers, "x-ratelimit-reset")) ?? + parseRateLimitDelay(headerValue(headers, "retry-after"), now) ?? + parseRateLimitDelay(headerValue(headers, "x-ratelimit-reset"), now) ?? 1_000 * 2 ** Math.min(attempt, 5) ); } @@ -1591,7 +1951,7 @@ const rateLimitedRequest = Effect.fnUntraced(function* <A>( if (response.status !== 429 || attempt >= DEPLOY_RATE_LIMIT_MAX_RETRIES) { return response; } - const delayMs = rateLimitDelayMillis(response.headers, attempt); + const delayMs = rateLimitDelayMillis(response.headers, attempt, yield* Clock.currentTimeMillis); yield* output.raw( `Rate limit exceeded while ${action}. Retrying in ${rateLimitDelayText(delayMs)}.\n`, "stderr", @@ -1610,15 +1970,18 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( bundleOnly: boolean, ) { const output = yield* Output; - const files = yield* Effect.tryPromise({ - try: async () => { - const form = await writeSourceDeployForm(sourceRoot, workdir, config, metadata, (text) => - output.raw(text, "stderr"), - ); - return form.getAll("file").flatMap((part) => (part instanceof Blob ? [part] : [])); - }, - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const form = yield* writeSourceDeployForm(sourceRoot, workdir, config, metadata, (text) => + output.raw(text, "stderr"), + ).pipe( + Effect.mapError( + (error) => + new FunctionsOperationError({ + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + ), + ); + const files = form.getAll("file").flatMap((part) => (part instanceof Blob ? [part] : [])); const response = yield* rateLimitedRequest(`deploying function ${config.slug}`, () => api .executeRaw(operationDefinitions.v1DeployAFunction, { @@ -1645,18 +2008,16 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( ); const body = yield* response.body; if (response.status !== 201) { - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected deploy status ${response.status}: ${formatUnexpectedStatusBody(body)}`, + }); } // A 201 whose body is not the expected JSON is an API-response problem, not a // transport failure — surface it via FunctionsApiStatusError so it classifies // as api_status rather than network. return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(JSON.parse(body)), + try: () => decodeDeployFunctionResponse(decodeJsonText(body)), catch: (error) => new FunctionsApiStatusError({ status: response.status, @@ -1735,7 +2096,9 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } } - return yield* Effect.fail(lastError ?? new Error("failed to bulk update")); + return yield* Effect.fail( + lastError ?? new FunctionsOperationError({ message: "failed to bulk update" }), + ); }); const upsertBundledFunction = Effect.fnUntraced(function* ( @@ -1790,7 +2153,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( // FunctionsApiStatusError so it classifies as api_status not network. const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.try({ - try: () => decodeDeployFunctionResponse(JSON.parse(body)), + try: () => decodeDeployFunctionResponse(decodeJsonText(body)), catch: (error) => new FunctionsApiStatusError({ status: response.value.status, @@ -1818,7 +2181,9 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( } } - return yield* Effect.fail(lastError ?? new Error("failed to upsert function")); + return yield* Effect.fail( + lastError ?? new FunctionsOperationError({ message: "failed to upsert function" }), + ); }); const deleteRemoteFunction = Effect.fnUntraced(function* ( @@ -1837,43 +2202,46 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( return; } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected delete function status ${response.status}: ${body}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, + }); }); export const discoverFunctionSlugs = Effect.fnUntraced(function* ( projectRoot: string, configDeclaredFunctions: Readonly<Record<string, ManifestFunctionConfig>>, ) { + const fs = yield* FileSystem.FileSystem; const functionsDir = join(projectRoot, SUPABASE_FUNCTIONS_DIR); const slugs: string[] = []; - const entries = yield* Effect.tryPromise({ - try: () => readdir(functionsDir, { withFileTypes: true }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe( - Effect.catch((error) => { - return "code" in error && error.code === "ENOENT" - ? Effect.succeed(undefined) - : Effect.fail(error); - }), + const entries = yield* fs.readDirectory(functionsDir).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + Predicate.isTagged(cause.reason, "NotFound") + ? Effect.succeed(Option.none()) + : Effect.fail(cause), + ), ); - if (entries !== undefined) { - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) { + if (Option.isSome(entries)) { + for (const entry of entries.value.sort((left, right) => left.localeCompare(right))) { + const info = yield* fs.stat(join(functionsDir, entry)).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + Predicate.isTagged(cause.reason, "NotFound") + ? Effect.succeed(Option.none()) + : Effect.fail(cause), + ), + ); + if (Option.isNone(info) || info.value.type !== "Directory") { continue; } - const slug = entry.name; + const slug = entry; if (validateFunctionSlugMessage(slug) !== undefined) { continue; } - const hasDefaultEntrypoint = yield* Effect.promise(() => - isFile(defaultFunctionEntrypoint(functionsDir, slug)), - ); + const hasDefaultEntrypoint = yield* isFile(defaultFunctionEntrypoint(functionsDir, slug)); if (hasDefaultEntrypoint) { slugs.push(slug); } @@ -1912,7 +2280,7 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { const resolved: ResolvedDeployFunctionConfig[] = []; const fallbackImportMapPath = join(functionsDir, "import_map.json"); - const fallbackExists = yield* Effect.promise(() => isFile(fallbackImportMapPath)); + const fallbackExists = yield* isFile(fallbackImportMapPath); const importMapOverride = Option.match(input.importMapOverride, { onNone: () => "", @@ -1965,11 +2333,11 @@ export const resolveFunctionConfigs = Effect.fnUntraced(function* (input: { const denoJsonc = join(functionDir, "deno.jsonc"); const deprecatedImportMap = join(functionDir, "import_map.json"); - if (yield* Effect.promise(() => isFile(denoJson))) { + if (yield* isFile(denoJson)) { importMap = denoJson; - } else if (yield* Effect.promise(() => isFile(denoJsonc))) { + } else if (yield* isFile(denoJsonc)) { importMap = denoJsonc; - } else if (yield* Effect.promise(() => isFile(deprecatedImportMap))) { + } else if (yield* isFile(deprecatedImportMap)) { importMap = deprecatedImportMap; seenDeprecatedImportMap.add(slug); } else if (fallbackExists) { @@ -2037,19 +2405,14 @@ const deployViaApi = Effect.fnUntraced(function* ( // uploads any reachable import unbounded; #5755 widened the TS boundary from // the workdir to the git root so monorepo imports outside the workdir deploy). // Such files upload with Go-`toRelPath`-style `../`-relative names. - const sourceRoot = yield* Effect.tryPromise({ - try: () => resolveFunctionsSourceRoot(projectRoot), - catch: (error) => (error instanceof Error ? error : new Error(String(error))), - }); + const sourceRoot = yield* resolveFunctionsSourceRoot(projectRoot); const enabled = configs.filter((config) => config.enabled); for (const skipped of configs.filter((config) => !config.enabled)) { yield* output.raw(`Skipping disabled Function: ${skipped.slug}\n`, "stderr"); } if (enabled.length === 0) { - return yield* Effect.fail( - new NoFunctionsToDeployError({ message: "All Functions are up to date." }), - ); + return yield* new NoFunctionsToDeployError({ message: "All Functions are up to date." }); } const remoteBySlug = enabled.some((config) => config.verifyJwt === undefined) @@ -2110,7 +2473,10 @@ const deployViaApi = Effect.fnUntraced(function* ( } if (deployed.length === 0) { - return yield* Effect.fail(new AggregateError(causes, messages.join("\n"))); + return yield* new FunctionsOperationError({ + message: messages.join("\n"), + causes, + }); } const updated = yield* bulkUpdateRemoteFunctions(api, projectRef, deployed).pipe( @@ -2122,7 +2488,10 @@ const deployViaApi = Effect.fnUntraced(function* ( causes.push(updated.error); } if (messages.length > 0) { - return yield* Effect.fail(new AggregateError(causes, messages.join("\n"))); + return yield* new FunctionsOperationError({ + message: messages.join("\n"), + causes, + }); } }); @@ -2229,9 +2598,7 @@ const pruneFunctions = Effect.fnUntraced(function* ( ].join("\n")}\n\n`; const confirmed = yield* legacyPromptYesNo(output, yes, prompt, false); if (!confirmed) { - return yield* Effect.fail( - new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }), - ); + return yield* new FunctionDeployCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); } for (const slug of toDelete) { @@ -2240,225 +2607,222 @@ const pruneFunctions = Effect.fnUntraced(function* ( } }); -export function deployFunctions<ResolveError, ResolveRequirements>( +export const deployFunctions = Effect.fn("functions.deploy")(function* < + ResolveError, + ResolveRequirements, +>( flags: FunctionsDeployFlags, dependencies: DeployFunctionsDependencies<ResolveError, ResolveRequirements>, ) { - return Effect.gen(function* () { - const output = yield* Output; - const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); - const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); - const commandPath = ["functions", "deploy"] as const; - // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — mirrors - // cobra's `Changed()`-driven `MarkFlagsMutuallyExclusive`, so it's only used for the - // mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) - // key off the resolved `flags.useApi` value instead, matching Go's own `if useApi`. - const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); - const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); - const explicitLegacyBundle = hasExplicitLongFlag( - dependencies.rawArgs, - commandPath, - "legacy-bundle", - ); + const output = yield* Output; + const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); + const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); + const commandPath = ["functions", "deploy"] as const; + // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — mirrors + // cobra's `Changed()`-driven `MarkFlagsMutuallyExclusive`, so it's only used for the + // mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) + // key off the resolved `flags.useApi` value instead, matching Go's own `if useApi`. + const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); + const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); + const explicitLegacyBundle = hasExplicitLongFlag( + dependencies.rawArgs, + commandPath, + "legacy-bundle", + ); - const changedModes = [ - explicitUseApi ? "use-api" : undefined, - explicitUseDocker ? "use-docker" : undefined, - explicitLegacyBundle ? "legacy-bundle" : undefined, - ].filter((flag): flag is string => flag !== undefined); + const changedModes = [ + explicitUseApi ? "use-api" : undefined, + explicitUseDocker ? "use-docker" : undefined, + explicitLegacyBundle ? "legacy-bundle" : undefined, + ].filter((flag): flag is string => flag !== undefined); - if (changedModes.length > 1) { - return yield* Effect.fail( - new ConflictingFunctionDeployFlagsError({ - message: cobraMutuallyExclusiveErrorMessage(FUNCTIONS_BUNDLER_MUTEX_GROUP, changedModes), - }), - ); - } + if (changedModes.length > 1) { + return yield* new ConflictingFunctionDeployFlagsError({ + message: cobraMutuallyExclusiveErrorMessage(FUNCTIONS_BUNDLER_MUTEX_GROUP, changedModes), + }); + } - // Go parity (`cmd/functions.go:79-80`): `if useApi { useDocker = false }` mutates the - // resolved boolean, not a presence flag — `--use-api=false` alone must NOT force the - // API path, it should fall through to whatever `--use-docker`/`--legacy-bundle` - // already resolved to. - const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); - const configuredJobs = Option.getOrElse(flags.jobs, () => 1); - const jobs = configuredJobs === 0 ? 1 : configuredJobs; - // Go parity (`cmd/functions.go:79-82`): the guard is `if useApi { ... } else if - // maxJobs > 1 { error }` — keyed on the resolved `--use-api` value alone, not on - // whether local bundling (Docker/legacy-bundle) is in play. - if (!flags.useApi && jobs > 1) { - return yield* Effect.fail(new Error("--jobs must be used together with --use-api")); - } - - const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - // `@supabase/config` merges the matching `[remotes.*]` block over the base - // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved - // config already reflects any remote function/edge_runtime overrides. - // In the legacy shell this also runs the same `Config.Validate`/dotenv/ - // env-override pipeline `start`/`stop`/`status` already go through — see - // `functions-config.ts`. Go: `flags.LoadConfig` runs before validating any - // slug (`deploy.go:22-28`), so this must precede the loop below too — an - // invalid `config.toml` is reported ahead of a malformed slug when both - // are wrong (review round on CLI-1963). - const context = yield* loadFunctionsProjectConfig({ - projectRoot: dependencies.projectRoot, - projectRef, - goConfigCompat: dependencies.goConfigCompat, + // Go parity (`cmd/functions.go:79-80`): `if useApi { useDocker = false }` mutates the + // resolved boolean, not a presence flag — `--use-api=false` alone must NOT force the + // API path, it should fall through to whatever `--use-docker`/`--legacy-bundle` + // already resolved to. + const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); + const configuredJobs = Option.getOrElse(flags.jobs, () => 1); + const jobs = configuredJobs === 0 ? 1 : configuredJobs; + // Go parity (`cmd/functions.go:79-82`): the guard is `if useApi { ... } else if + // maxJobs > 1 { error }` — keyed on the resolved `--use-api` value alone, not on + // whether local bundling (Docker/legacy-bundle) is in play. + if (!flags.useApi && jobs > 1) { + return yield* new FunctionsOperationError({ + message: "--jobs must be used together with --use-api", }); + } - if (flags.functionNames.length > 0) { - for (const slug of flags.functionNames) { - yield* validateDeploySlug(slug); - } - } + const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); + // `@supabase/config` merges the matching `[remotes.*]` block over the base + // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved + // config already reflects any remote function/edge_runtime overrides. + // In the legacy shell this also runs the same `Config.Validate`/dotenv/ + // env-override pipeline `start`/`stop`/`status` already go through — see + // `functions-config.ts`. Go: `flags.LoadConfig` runs before validating any + // slug (`deploy.go:22-28`), so this must precede the loop below too — an + // invalid `config.toml` is reported ahead of a malformed slug when both + // are wrong (review round on CLI-1963). + const context = yield* loadFunctionsProjectConfig({ + projectRoot: dependencies.projectRoot, + projectRef, + goConfigCompat: dependencies.goConfigCompat, + }); - const noVerifyJwtOverride = explicitBooleanFlag( - dependencies.rawArgs, - ["functions", "deploy"], - "no-verify-jwt", - flags.noVerifyJwt, - ); - // Go gates the bundler's `--verbose` on `viper.GetBool("DEBUG")` - // (`bundle.go:59`), so `--debug=false` must resolve to `false` — a plain - // presence check would get that backwards (same rule as `download.ts`'s - // own `--debug` read; the `SUPABASE_DEBUG` env fallback is deferred - // there too). - const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; - const deployConfig = context.loaded?.config; - const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( - context.denoVersion, - dependencies.edgeRuntimeVersion, - ); - const configFunctions = yield* inferFunctionsManifest({ - cwd: dependencies.projectRoot, - config: deployConfig, - }); - const configDeclaredFunctions = deployConfig?.functions ?? {}; - const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); - yield* validateConfigFunctionSlugs(configDeclaredFunctions); - const slugs = - flags.functionNames.length > 0 - ? [...flags.functionNames] - : yield* discoverFunctionSlugs(dependencies.projectRoot, configDeclaredFunctions); - - if (slugs.length === 0) { - return yield* Effect.fail( - new NoFunctionsToDeployError({ - // Go: `errors.Errorf("No Functions specified or found in %s", - // utils.Bold(utils.FunctionsDir))` (`internal/functions/deploy/deploy.go:35`) — - // the legacy handler injects the bold styling via `styleEmphasis`. Styling is - // text-mode only: in `--output-format json`/`stream-json` this message lands in - // the structured error payload, which must stay free of ANSI escapes. - message: `No Functions specified or found in ${ - output.format === "text" - ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) - : SUPABASE_FUNCTIONS_DIR - }`, - }), - ); + if (flags.functionNames.length > 0) { + for (const slug of flags.functionNames) { + yield* validateDeploySlug(slug); } + } - const uniqueSlugs = [...new Set(slugs)]; - const configs = yield* resolveFunctionConfigs({ - slugs: uniqueSlugs, - cwd: dependencies.flagCwd, - projectRoot: dependencies.projectRoot, - supabaseDir: dependencies.supabaseDir, - configFunctions, - configDeclaredFunctions, - rawConfigFunctions, - importMapOverride: flags.importMap, - noVerifyJwtOverride, + const noVerifyJwtOverride = explicitBooleanFlag( + dependencies.rawArgs, + ["functions", "deploy"], + "no-verify-jwt", + flags.noVerifyJwt, + ); + // Go gates the bundler's `--verbose` on `viper.GetBool("DEBUG")` + // (`bundle.go:59`), so `--debug=false` must resolve to `false` — a plain + // presence check would get that backwards (same rule as `download.ts`'s + // own `--debug` read; the `SUPABASE_DEBUG` env fallback is deferred + // there too). + const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; + const deployConfig = context.loaded?.config; + const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( + context.denoVersion, + dependencies.edgeRuntimeVersion, + ); + const configFunctions = yield* inferFunctionsManifest({ + cwd: dependencies.projectRoot, + config: deployConfig, + }); + const configDeclaredFunctions = deployConfig?.functions ?? {}; + const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); + yield* validateConfigFunctionSlugs(configDeclaredFunctions); + const slugs = + flags.functionNames.length > 0 + ? [...flags.functionNames] + : yield* discoverFunctionSlugs(dependencies.projectRoot, configDeclaredFunctions); + + if (slugs.length === 0) { + return yield* new NoFunctionsToDeployError({ + // Go: `errors.Errorf("No Functions specified or found in %s", + // utils.Bold(utils.FunctionsDir))` (`internal/functions/deploy/deploy.go:35`) — + // the legacy handler injects the bold styling via `styleEmphasis`. Styling is + // text-mode only: in `--output-format json`/`stream-json` this message lands in + // the structured error payload, which must stay free of ANSI escapes. + message: `No Functions specified or found in ${ + output.format === "text" ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) : SUPABASE_FUNCTIONS_DIR + }`, }); - const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; + } - const deployWithApi = deployViaApi( - projectRef, - dependencies.projectRoot, - configs, - dependencies.api, - jobs, - ).pipe( - Effect.as(true), - Effect.catchIf( - (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, - (error) => - (output.format === "text" - ? output.raw(`${error.message}\n`, "stderr") - : output.success(error.message, { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }) - ).pipe(Effect.as(false)), - ), - ); + const uniqueSlugs = [...new Set(slugs)]; + const configs = yield* resolveFunctionConfigs({ + slugs: uniqueSlugs, + cwd: dependencies.flagCwd, + projectRoot: dependencies.projectRoot, + supabaseDir: dependencies.supabaseDir, + configFunctions, + configDeclaredFunctions, + rawConfigFunctions, + importMapOverride: flags.importMap, + noVerifyJwtOverride, + }); + const dashboardUrl = `${dependencies.dashboardUrl}/project/${projectRef}/functions`; - const styleWarning = dependencies.styleWarning ?? ((text: string) => text); - const deployed = useLocalBundler - ? yield* Effect.gen(function* () { - if (!(yield* isDockerRunning())) { - yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); - return yield* deployWithApi; - } + const deployWithApi = deployViaApi( + projectRef, + dependencies.projectRoot, + configs, + dependencies.api, + jobs, + ).pipe( + Effect.as(true), + Effect.catchIf( + (error): error is NoFunctionsToDeployError => error instanceof NoFunctionsToDeployError, + (error) => + (output.format === "text" + ? output.raw(`${error.message}\n`, "stderr") + : output.success(error.message, { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }) + ).pipe(Effect.as(false)), + ), + ); - // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs - // "never touched" distinction `resolveDockerNetworkMode` needs to - // decide whether `SUPABASE_NETWORK_ID` applies — see that - // function's own doc comment. `SUPABASE_NETWORK_ID` (env or - // project dotenv) is legacy-shell-only — same Go-viper-parity gate - // as `context.projectEnvValues` itself (`undefined` in `next`). - const networkMode = resolveDockerNetworkMode({ - explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), - envOverride: - context.projectEnvValues === undefined - ? undefined - : legacyViperEnvStringWithProjectFallback( - "SUPABASE_NETWORK_ID", - context.projectEnvValues, - ), - projectId: context.projectId, - }); - yield* deployViaDocker({ - projectId: context.projectId, - projectRef, - edgeRuntimeVersion, - functionsDir: join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), - configs, - api: dependencies.api, - networkMode, - verbose: debugEnabled, - styleEmphasis, - projectEnvValues: context.projectEnvValues, - }); - return true; - }) - : yield* deployWithApi; + const styleWarning = dependencies.styleWarning ?? ((text: string) => text); + const deployed = useLocalBundler + ? yield* Effect.gen(function* () { + if (!(yield* isDockerRunning())) { + yield* output.raw(`${styleWarning("WARNING:")} Docker is not running\n`, "stderr"); + return yield* deployWithApi; + } - if (!deployed) { - return; - } + // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs + // "never touched" distinction `resolveDockerNetworkMode` needs to + // decide whether `SUPABASE_NETWORK_ID` applies — see that + // function's own doc comment. `SUPABASE_NETWORK_ID` (env or + // project dotenv) is legacy-shell-only — same Go-viper-parity gate + // as `context.projectEnvValues` itself (`undefined` in `next`). + const networkMode = resolveDockerNetworkMode({ + explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), + envOverride: + context.projectEnvValues === undefined + ? undefined + : yield* legacyViperEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + context.projectEnvValues, + ), + projectId: context.projectId, + }); + yield* deployViaDocker({ + projectId: context.projectId, + projectRef, + edgeRuntimeVersion, + functionsDir: join(dependencies.projectRoot, SUPABASE_FUNCTIONS_DIR), + configs, + api: dependencies.api, + networkMode, + verbose: debugEnabled, + styleEmphasis, + projectEnvValues: context.projectEnvValues, + }); + return true; + }) + : yield* deployWithApi; - if (output.format === "text") { - // Go: `fmt.Printf("Deployed Functions on project %s: %s\n", - // utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", "))` - // (`internal/functions/deploy/deploy.go:70`) — the legacy handler injects - // the aqua styling via `styleIdentifier` (stdout-bound, so its TTY gate - // must check stdout); next stays plain. Go joins the raw `slugs` list, not - // the deduped set, so `functions deploy foo foo` prints "foo, foo". - yield* output.raw( - `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, - ); - yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); - } else { - yield* output.success("Deployed Functions.", { - project_ref: projectRef, - functions: uniqueSlugs, - dashboard_url: dashboardUrl, - }); - } + if (!deployed) { + return; + } - if (flags.prune) { - yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); - } - }).pipe(Effect.withSpan("functions.deploy")); -} + if (output.format === "text") { + // Go: `fmt.Printf("Deployed Functions on project %s: %s\n", + // utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", "))` + // (`internal/functions/deploy/deploy.go:70`) — the legacy handler injects + // the aqua styling via `styleIdentifier` (stdout-bound, so its TTY gate + // must check stdout); next stays plain. Go joins the raw `slugs` list, not + // the deduped set, so `functions deploy foo foo` prints "foo, foo". + yield* output.raw( + `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, + ); + yield* output.raw(`You can inspect your deployment in the Dashboard: ${dashboardUrl}\n`); + } else { + yield* output.success("Deployed Functions.", { + project_ref: projectRef, + functions: uniqueSlugs, + dashboard_url: dashboardUrl, + }); + } + + if (flags.prune) { + yield* pruneFunctions(projectRef, configs, dependencies.api, dependencies.yes ?? false); + } +}); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index eaf81c3274..6b3dcfeb52 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -1,16 +1,102 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { describe, expect, it } from "vitest"; +import { Effect, FileSystem, Result, Schema } from "effect"; +import type { PlatformError } from "effect/PlatformError"; +import * as EffectPath from "effect/Path"; import { - buildDockerBinds, + buildDockerBinds as buildDockerBindsEffect, dockerBindHostPath, type ResolvedDeployFunctionConfig, } from "./deploy.ts"; import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; +const { join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); +const encodeJsonText = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const withFileSystem = <A>( + effect: Effect.Effect<A, PlatformError, FileSystem.FileSystem>, +): Effect.Effect<A, PlatformError, never> => effect.pipe(Effect.provide(BunFileSystem.layer)); + +const makeDirectory = (path: string, recursive = false) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive }); + }), + ); +const makeTempDirectory = (prefix: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ directory: tmpdir(), prefix }); + }), + ); +const realPath = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.realPath(path); + }), + ); +const remove = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true, force: true }); + }), + ); +const writeFileString = (path: string, contents: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }), + ); +const symlink = (target: string, path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.symlink(target, path); + }), + ); + +type PromiseBuildOptions = Omit< + NonNullable<Parameters<typeof buildDockerBindsEffect>[4]>, + "onWarning" +> & { + readonly onWarning?: (message: string) => Effect.Effect<void>; +}; + +function buildDockerBinds( + projectId: string, + functionsDir: string, + outputDir: string, + config: ResolvedDeployFunctionConfig, + options?: PromiseBuildOptions, +) { + const onWarning = options?.onWarning; + return buildDockerBindsEffect( + projectId, + functionsDir, + outputDir, + config, + options === undefined + ? undefined + : { + ...options, + onWarning: onWarning === undefined ? undefined : (message) => onWarning(message), + }, + ).pipe(Effect.provide(BunFileSystem.layer)); +} + +const warningCollector = + (warnings: Array<string>) => + (message: string): Effect.Effect<void> => + Effect.sync(() => warnings.push(message)); + /** * `../../` from `<root>/supabase/functions/hello/deno.json`'s directory * lands at `<root>/supabase/_vendor/package/dist/index.mjs` — deliberately @@ -23,7 +109,7 @@ const VENDOR_TARGET_RELATIVE = "../../_vendor/package/dist/index.mjs"; /** Import-maps spec: a value for a "/"-suffixed key should itself end in "/". */ const VENDOR_TARGET_RELATIVE_SLASH = "../../_vendor/package/dist/index.mjs/"; -async function createFunctionProjectWithDenoJson( +function createFunctionProjectWithDenoJson( denoJson: Readonly<Record<string, unknown>>, indexTsContents: string, ) { @@ -33,477 +119,564 @@ async function createFunctionProjectWithDenoJson( // dotted-but-nonexistent specifier — an unresolved symlink prefix would // make every path below "outside the source root" and mask the real // assertions this file is testing. - const root = await realpath(await mkdtemp(join(tmpdir(), "deploy-import-scanner-"))); - const functionsDir = join(root, "supabase", "functions"); - const functionDir = join(functionsDir, "hello"); - const outputDir = join(root, "out"); - - await mkdir(functionDir, { recursive: true }); - await mkdir(outputDir, { recursive: true }); - - const entrypoint = join(functionDir, "index.ts"); - const importMap = join(functionDir, "deno.json"); - await writeFile(entrypoint, indexTsContents); - await writeFile(importMap, JSON.stringify(denoJson)); - - const config: ResolvedDeployFunctionConfig = { - slug: "hello", - enabled: true, - entrypoint, - importMap, - staticFiles: [], - env: {}, - }; - - return { root, functionsDir, functionDir, outputDir, config }; + return Effect.gen(function* () { + const root = yield* realPath(yield* makeTempDirectory("deploy-import-scanner-")); + const functionsDir = join(root, "supabase", "functions"); + const functionDir = join(functionsDir, "hello"); + const outputDir = join(root, "out"); + + yield* makeDirectory(functionDir, true); + yield* makeDirectory(outputDir, true); + + const entrypoint = join(functionDir, "index.ts"); + const importMap = join(functionDir, "deno.json"); + yield* writeFileString(entrypoint, indexTsContents); + yield* writeFileString(importMap, encodeJsonText(denoJson)); + + const config: ResolvedDeployFunctionConfig = { + slug: "hello", + enabled: true, + entrypoint, + importMap, + staticFiles: [], + env: {}, + }; + + return { root, functionsDir, functionDir, outputDir, config }; + }); } -async function createHelloFunctionProject( +function createHelloFunctionProject( denoJsonImports: Record<string, string>, indexTsContents: string, ) { return createFunctionProjectWithDenoJson({ imports: denoJsonImports }, indexTsContents); } -async function writeVendorIndexFile(root: string) { - const vendorDir = join(root, "supabase", "_vendor", "package", "dist"); - await mkdir(vendorDir, { recursive: true }); - const vendorIndexPath = join(vendorDir, "index.mjs"); - await writeFile(vendorIndexPath, "export const core = 1;\n"); - return vendorIndexPath; -} - -async function createVendoredFunctionProject(indexTsContents: string) { - const project = await createHelloFunctionProject( - { "@supabase/server": VENDOR_TARGET_RELATIVE }, - indexTsContents, - ); - const vendorIndexPath = await writeVendorIndexFile(project.root); - return { ...project, vendorIndexPath }; -} - -async function createSlashVendoredFunctionProject(indexTsContents: string) { - const project = await createHelloFunctionProject( - { "@supabase/server/": VENDOR_TARGET_RELATIVE_SLASH }, - indexTsContents, - ); - const vendorIndexPath = await writeVendorIndexFile(project.root); - return { ...project, vendorIndexPath }; -} - -describe("buildDockerBinds — import-map key matching (spec-strict) and the file-mapped-key guard", () => { - it("drops a specifier reachable only through a JSDoc comment, now via a no-match on the unqualified bare key (not the extension guard)", async () => { - // Import-maps spec: a bare key ("@supabase/server", no trailing slash) - // matches only exactly, so "@supabase/server/core" no longer substitutes - // at all here — it is dropped as an unresolvable bare specifier before - // the final-segment guard ever runs. Kept as its own test because it - // pins the exact field-reported shape; see the "final-segment guard" - // test below for the guard itself under a spec-valid `/`-suffixed key. - const { root, functionsDir, outputDir, config, vendorIndexPath } = - await createVendoredFunctionProject( - [ - "/**", - " * @example", - ' * import { core } from "@supabase/server/core";', - " */", - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - // The vendor file is still bound via the import-map target walk - // (independent of whether the entrypoint's own specifier matched). - expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); - expect(binds.some((bind) => bind.includes("index.mjs/core"))).toBe(false); - expect(warnings).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("rejects with a FunctionImportNotDirectoryError carrying a clean 'not a directory' message (not a raw ENOTDIR) for a real import reaching a dotted final segment through a `/`-suffixed file-mapped key", async () => { - const { root, functionsDir, outputDir, config } = await createSlashVendoredFunctionProject( - [ - 'import { extra } from "@supabase/server/extra.ts";', - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - - try { - let caught: unknown; - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async () => {}, - }); - } catch (error) { - caught = error; - } - - expect(caught).toBeInstanceOf(FunctionImportNotDirectoryError); - expect((caught as FunctionImportNotDirectoryError)._tag).toBe( - "FunctionImportNotDirectoryError", - ); - expect((caught as FunctionImportNotDirectoryError).message).toBe( - "failed to read file: open supabase/_vendor/package/dist/index.mjs/extra.ts: not a directory", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("no longer prefix-matches a bare (non-`/`-suffixed) key: a longer specifier stays bare and is skipped without a warning", async () => { - const { root, functionsDir, outputDir, config } = await createVendoredFunctionProject( - [ - 'import { extra } from "@supabase/server/extra.ts";', - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - expect(binds.some((bind) => bind.includes("extra.ts"))).toBe(false); - expect(warnings).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("still substitutes on an exact match against a bare key", async () => { - const { root, functionsDir, outputDir, config, vendorIndexPath } = - await createVendoredFunctionProject( - [ - 'import { server } from "@supabase/server";', - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); - expect(warnings).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("warns ENOENT-style for a genuinely missing relative import, unaffected by the file-mapped-key guard", async () => { - const { root, functionsDir, outputDir, config } = await createVendoredFunctionProject( - ['import { missing } from "./missing.ts";', 'Deno.serve(() => new Response("ok"));', ""].join( - "\n", - ), - ); - const warnings: Array<string> = []; - - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - const matches = warnings.filter( - (warning) => - warning.includes("failed to read file: open ") && - warning.includes(": no such file or directory"), - ); - expect(matches).toHaveLength(1); - expect(matches[0]).toContain("missing.ts"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("the final-segment guard still covers the original crash shape under a spec-valid `/`-suffixed map: a JSDoc-only mention is dropped silently", async () => { - const { root, functionsDir, outputDir, config } = await createSlashVendoredFunctionProject( - [ - "/**", - " * @example", - ' * import { core } from "@supabase/server/core";', - " */", - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - expect(binds.some((bind) => bind.includes("index.mjs/core"))).toBe(false); - expect(warnings.some((warning) => warning.includes("index.mjs/core"))).toBe(false); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("does not crash when an unreferenced `/`-suffixed import-map target resolves through a file, with no options passed", async () => { - // Regression for a bug found while writing the test above: - // `forEachLocalImportMapTarget` enumerates every import-map VALUE - // unconditionally (regardless of whether the entrypoint references it), - // and Bun's `realpath` — unlike Node's — throws ENOTDIR on a - // trailing-slash path through a file. A spec-valid `/`-suffixed value - // (which SHOULD end in "/") pointing at a real file used to crash - // `buildDockerBinds` with a raw ENOTDIR here, with no options passed — - // exactly how the real `functions deploy` bundling call site invokes it. - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - { "@x/": VENDOR_TARGET_RELATIVE_SLASH }, - 'Deno.serve(() => new Response("ok"));\n', - ); - await writeVendorIndexFile(root); - - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("skips an unreferenced import-map target that resolves through a file, regardless of skipMissingImportMapTargets", async () => { - // ENOTDIR (a target routed through a file) is now always skippable, with - // its own wording distinct from the ENOENT "missing" case below — see - // "skips a genuinely missing import-map target" for the option's actual - // gate. - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - { "@x": `${VENDOR_TARGET_RELATIVE}/sub.ts` }, - 'Deno.serve(() => new Response("ok"));\n', - ); - await writeVendorIndexFile(root); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - expect(binds.some((bind) => bind.includes("index.mjs"))).toBe(false); - expect( - warnings.some((warning) => - warning.includes("Skipping import map target that is not a directory"), - ), - ).toBe(true); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("skips a genuinely missing import-map target only when skipMissingImportMapTargets is set", async () => { - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - { "@missing": "../../does-not-exist.ts" }, - 'Deno.serve(() => new Response("ok"));\n', - ); - - try { - let threwWithoutOption = false; - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config); - } catch { - threwWithoutOption = true; - } - expect(threwWithoutOption).toBe(true); - - const warnings: Array<string> = []; - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - skipMissingImportMapTargets: true, - }); - - expect(binds.some((bind) => bind.includes("does-not-exist"))).toBe(false); - expect( - warnings.some((warning) => warning.includes("Skipping missing import map target")), - ).toBe(true); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("drops a `/`-suffixed key whose value lacks a trailing slash (spec-invalid mapping), instead of fabricating a concatenated path", async () => { - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - { "pkg/": VENDOR_TARGET_RELATIVE }, - [ - 'import { core } from "pkg/core.ts";', - 'import { core2 } from "pkg//core.ts";', - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - await writeVendorIndexFile(root); - const warnings: Array<string> = []; - - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - // Pre-fix, "pkg/core.ts" fabricated "<vendor>/index.mjscore.ts" (no - // separator) and "pkg//core.ts" fabricated "<vendor>/index.mjs/core.ts" - // (a genuine through-a-file crash shape) — both warned or threw. - expect(warnings).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("ignores an empty-string import-map key (spec) without crashing; other mappings still resolve", async () => { - const { root, functionsDir, functionDir, outputDir, config } = await createHelloFunctionProject( - { "": "./x.ts", "@supabase/server": VENDOR_TARGET_RELATIVE }, - [ - 'import { server } from "@supabase/server";', - 'Deno.serve(() => new Response("ok"));', - "", - ].join("\n"), - ); - await writeFile(join(functionDir, "x.ts"), "export const x = 1;\n"); - const vendorIndexPath = await writeVendorIndexFile(root); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); - expect(warnings).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } +function writeVendorIndexFile(root: string) { + return Effect.gen(function* () { + const vendorDir = join(root, "supabase", "_vendor", "package", "dist"); + yield* makeDirectory(vendorDir, true); + const vendorIndexPath = join(vendorDir, "index.mjs"); + yield* writeFileString(vendorIndexPath, "export const core = 1;\n"); + return vendorIndexPath; }); +} - it("resolves via the longest matching `/`-suffixed key when two keys compete", async () => { - const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( - { - "@v/": "../../../dirA/", - "@v/deep/": "../../../dirB/", - }, - ['import { mod } from "@v/deep/mod.ts";', 'Deno.serve(() => new Response("ok"));', ""].join( - "\n", - ), +function createVendoredFunctionProject(indexTsContents: string) { + return Effect.gen(function* () { + const project = yield* createHelloFunctionProject( + { "@supabase/server": VENDOR_TARGET_RELATIVE }, + indexTsContents, ); - await mkdir(join(root, "dirA"), { recursive: true }); - await mkdir(join(root, "dirB"), { recursive: true }); - const modPath = join(root, "dirB", "mod.ts"); - await writeFile(modPath, "export const mod = 2;\n"); - const warnings: Array<string> = []; - - try { - const binds = await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - // Proves the LONGER key ("@v/deep/") won: the walker followed - // "@v/deep/mod.ts" through dirB and bound the resolved FILE. Had the - // shorter key incorrectly won, the walker would have tried - // "<dirA>/deep/mod.ts" instead (which does not exist). - expect(binds.some((bind) => dockerBindHostPath(bind) === modPath)).toBe(true); - expect(binds.some((bind) => bind.includes(join("dirA", "deep")))).toBe(false); - expect(warnings).toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } + const vendorIndexPath = yield* writeVendorIndexFile(project.root); + return { ...project, vendorIndexPath }; }); +} - it("no longer applies a scope whose name coincidentally shares a string prefix with the current file's directory (spec-strict scope matching)", async () => { - const { root, functionsDir, outputDir, config } = await createFunctionProjectWithDenoJson( - { - imports: { "@lib": "../../../scoped-test/fallback-lib.ts" }, - scopes: { - "../hell": { "@lib": "../../../scoped-test/definitely-not-real.ts" }, - }, - }, - ['import { lib } from "@lib";', 'Deno.serve(() => new Response("ok"));', ""].join("\n"), +function createSlashVendoredFunctionProject(indexTsContents: string) { + return Effect.gen(function* () { + const project = yield* createHelloFunctionProject( + { "@supabase/server/": VENDOR_TARGET_RELATIVE_SLASH }, + indexTsContents, ); - await mkdir(join(root, "scoped-test"), { recursive: true }); - await writeFile(join(root, "scoped-test", "fallback-lib.ts"), "export const lib = 1;\n"); - const warnings: Array<string> = []; - - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - skipMissingImportMapTargets: true, - }); - - // Scope name "../hell" resolves to ".../functions/hell" — the OLD bare - // `startsWith` rule let that match the entrypoint's OWN directory - // (".../functions/hello") purely as a string prefix ("hello" starts - // with "hell" as characters, not as a path segment). If that scope - // incorrectly applied, "@lib" would resolve to the scoped (nonexistent) - // target and the walker itself would emit a "failed to read file" - // warning for it — distinct from the constant "Skipping missing import - // map target" warning that the independent, unconditional - // target-enumeration walk always emits for that same value regardless - // of whether its scope matches anything. - expect( - warnings.some( - (warning) => warning.includes("failed to read file") && warning.includes("not-real"), - ), - ).toBe(false); - expect( - warnings.some( - (warning) => - warning.includes("Skipping missing import map target") && warning.includes("not-real"), - ), - ).toBe(true); - } finally { - await rm(root, { recursive: true, force: true }); - } + const vendorIndexPath = yield* writeVendorIndexFile(project.root); + return { ...project, vendorIndexPath }; }); +} - it("silently drops a trailing-slash directory-shaped specifier instead of crashing", async () => { - const { root, functionsDir, functionDir, outputDir, config } = await createHelloFunctionProject( - { "@dir/": "./sub/" }, - 'import "@dir/nested/";\nDeno.serve(() => new Response("ok"));\n', - ); - await mkdir(join(functionDir, "sub"), { recursive: true }); - const warnings: Array<string> = []; - - try { - await buildDockerBinds("test-project", functionsDir, outputDir, config, { - onWarning: async (message) => { - warnings.push(message); - }, - }); - - expect(warnings.some((warning) => warning.includes("nested"))).toBe(false); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); +describe("buildDockerBinds — import-map key matching (spec-strict) and the file-mapped-key guard", () => { + it("does not descend into a symlinked static glob directory", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + const assetsDir = join(root, "assets"); + const outsideDir = join(root, "outside-assets"); + const linkedDir = join(assetsDir, "linked"); + yield* makeDirectory(join(outsideDir, "nested"), true); + yield* writeFileString(join(outsideDir, "nested", "secret.txt"), "secret\n"); + yield* makeDirectory(assetsDir, true); + yield* symlink(outsideDir, linkedDir); + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, { + ...config, + staticFiles: [join(assetsDir, "**")], + }); + + expect(binds.some((bind) => bind.includes("secret.txt"))).toBe(false); + } finally { + yield* remove(root); + } + }), + )); + + it("includes symlinked static glob files without following symlinked directories", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + {}, + 'Deno.serve(() => new Response("ok"));\n', + ); + const assetsDir = join(root, "assets"); + const outsideFile = join(root, "outside-asset.txt"); + const linkedFile = join(assetsDir, "linked.txt"); + yield* writeFileString(outsideFile, "linked\n"); + yield* makeDirectory(assetsDir, true); + yield* symlink(outsideFile, linkedFile); + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, { + ...config, + staticFiles: [join(assetsDir, "**")], + }); + + expect(binds).toContain(`${outsideFile}:${outsideFile}:ro`); + } finally { + yield* remove(root); + } + }), + )); + + it("drops a specifier reachable only through a JSDoc comment, now via a no-match on the unqualified bare key (not the extension guard)", () => + Effect.runPromise( + Effect.gen(function* () { + // Import-maps spec: a bare key ("@supabase/server", no trailing slash) + // matches only exactly, so "@supabase/server/core" no longer substitutes + // at all here — it is dropped as an unresolvable bare specifier before + // the final-segment guard ever runs. Kept as its own test because it + // pins the exact field-reported shape; see the "final-segment guard" + // test below for the guard itself under a spec-valid `/`-suffixed key. + const { root, functionsDir, outputDir, config, vendorIndexPath } = + yield* createVendoredFunctionProject( + [ + "/**", + " * @example", + ' * import { core } from "@supabase/server/core";', + " */", + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: (message) => Effect.sync(() => warnings.push(message)), + }); + + // The vendor file is still bound via the import-map target walk + // (independent of whether the entrypoint's own specifier matched). + expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); + expect(binds.some((bind) => bind.includes("index.mjs/core"))).toBe(false); + expect(warnings).toEqual([]); + } finally { + yield* remove(root); + } + }), + )); + + it("rejects with a FunctionImportNotDirectoryError carrying a clean 'not a directory' message (not a raw ENOTDIR) for a real import reaching a dotted final segment through a `/`-suffixed file-mapped key", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createSlashVendoredFunctionProject( + [ + 'import { extra } from "@supabase/server/extra.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + + try { + const result = yield* Effect.result( + buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: () => Effect.void, + }), + ); + const caught = Result.isFailure(result) ? result.failure : undefined; + + expect(caught).toBeInstanceOf(FunctionImportNotDirectoryError); + expect((caught as FunctionImportNotDirectoryError)._tag).toBe( + "FunctionImportNotDirectoryError", + ); + expect((caught as FunctionImportNotDirectoryError).message).toBe( + "failed to read file: open supabase/_vendor/package/dist/index.mjs/extra.ts: not a directory", + ); + } finally { + yield* remove(root); + } + }), + )); + + it("no longer prefix-matches a bare (non-`/`-suffixed) key: a longer specifier stays bare and is skipped without a warning", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createVendoredFunctionProject( + [ + 'import { extra } from "@supabase/server/extra.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: (message) => Effect.sync(() => warnings.push(message)), + }); + + expect(binds.some((bind) => bind.includes("extra.ts"))).toBe(false); + expect(warnings).toEqual([]); + } finally { + yield* remove(root); + } + }), + )); + + it("still substitutes on an exact match against a bare key", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config, vendorIndexPath } = + yield* createVendoredFunctionProject( + [ + 'import { server } from "@supabase/server";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: (message) => Effect.sync(() => warnings.push(message)), + }); + + expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); + expect(warnings).toEqual([]); + } finally { + yield* remove(root); + } + }), + )); + + it("warns ENOENT-style for a genuinely missing relative import, unaffected by the file-mapped-key guard", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createVendoredFunctionProject( + [ + 'import { missing } from "./missing.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array<string> = []; + + try { + yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + const matches = warnings.filter( + (warning) => + warning.includes("failed to read file: open ") && + warning.includes(": no such file or directory"), + ); + expect(matches).toHaveLength(1); + expect(matches[0]).toContain("missing.ts"); + } finally { + yield* remove(root); + } + }), + )); + + it("the final-segment guard still covers the original crash shape under a spec-valid `/`-suffixed map: a JSDoc-only mention is dropped silently", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createSlashVendoredFunctionProject( + [ + "/**", + " * @example", + ' * import { core } from "@supabase/server/core";', + " */", + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + expect(binds.some((bind) => bind.includes("index.mjs/core"))).toBe(false); + expect(warnings.some((warning) => warning.includes("index.mjs/core"))).toBe(false); + } finally { + yield* remove(root); + } + }), + )); + + it("does not crash when an unreferenced `/`-suffixed import-map target resolves through a file, with no options passed", () => + Effect.runPromise( + Effect.gen(function* () { + // Regression for a bug found while writing the test above: + // `forEachLocalImportMapTarget` enumerates every import-map VALUE + // unconditionally (regardless of whether the entrypoint references it), + // and Bun's `realpath` — unlike Node's — throws ENOTDIR on a + // trailing-slash path through a file. A spec-valid `/`-suffixed value + // (which SHOULD end in "/") pointing at a real file used to crash + // `buildDockerBinds` with a raw ENOTDIR here, with no options passed — + // exactly how the real `functions deploy` bundling call site invokes it. + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + { "@x/": VENDOR_TARGET_RELATIVE_SLASH }, + 'Deno.serve(() => new Response("ok"));\n', + ); + yield* writeVendorIndexFile(root); + + try { + yield* buildDockerBinds("test-project", functionsDir, outputDir, config); + } finally { + yield* remove(root); + } + }), + )); + + it("skips an unreferenced import-map target that resolves through a file, regardless of skipMissingImportMapTargets", () => + Effect.runPromise( + Effect.gen(function* () { + // ENOTDIR (a target routed through a file) is now always skippable, with + // its own wording distinct from the ENOENT "missing" case below — see + // "skips a genuinely missing import-map target" for the option's actual + // gate. + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + { "@x": `${VENDOR_TARGET_RELATIVE}/sub.ts` }, + 'Deno.serve(() => new Response("ok"));\n', + ); + yield* writeVendorIndexFile(root); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + expect(binds.some((bind) => bind.includes("index.mjs"))).toBe(false); + expect( + warnings.some((warning) => + warning.includes("Skipping import map target that is not a directory"), + ), + ).toBe(true); + } finally { + yield* remove(root); + } + }), + )); + + it("skips a genuinely missing import-map target only when skipMissingImportMapTargets is set", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + { "@missing": "../../does-not-exist.ts" }, + 'Deno.serve(() => new Response("ok"));\n', + ); + + try { + const firstResult = yield* Effect.result( + buildDockerBinds("test-project", functionsDir, outputDir, config), + ); + const threwWithoutOption = Result.isFailure(firstResult); + expect(threwWithoutOption).toBe(true); + + const warnings: Array<string> = []; + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + skipMissingImportMapTargets: true, + }); + + expect(binds.some((bind) => bind.includes("does-not-exist"))).toBe(false); + expect( + warnings.some((warning) => warning.includes("Skipping missing import map target")), + ).toBe(true); + } finally { + yield* remove(root); + } + }), + )); + + it("drops a `/`-suffixed key whose value lacks a trailing slash (spec-invalid mapping), instead of fabricating a concatenated path", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + { "pkg/": VENDOR_TARGET_RELATIVE }, + [ + 'import { core } from "pkg/core.ts";', + 'import { core2 } from "pkg//core.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + yield* writeVendorIndexFile(root); + const warnings: Array<string> = []; + + try { + yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + // Pre-fix, "pkg/core.ts" fabricated "<vendor>/index.mjscore.ts" (no + // separator) and "pkg//core.ts" fabricated "<vendor>/index.mjs/core.ts" + // (a genuine through-a-file crash shape) — both warned or threw. + expect(warnings).toEqual([]); + } finally { + yield* remove(root); + } + }), + )); + + it("ignores an empty-string import-map key (spec) without crashing; other mappings still resolve", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, functionDir, outputDir, config } = + yield* createHelloFunctionProject( + { "": "./x.ts", "@supabase/server": VENDOR_TARGET_RELATIVE }, + [ + 'import { server } from "@supabase/server";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + yield* writeFileString(join(functionDir, "x.ts"), "export const x = 1;\n"); + const vendorIndexPath = yield* writeVendorIndexFile(root); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + expect(binds.some((bind) => dockerBindHostPath(bind) === vendorIndexPath)).toBe(true); + expect(warnings).toEqual([]); + } finally { + yield* remove(root); + } + }), + )); + + it("resolves via the longest matching `/`-suffixed key when two keys compete", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createHelloFunctionProject( + { + "@v/": "../../../dirA/", + "@v/deep/": "../../../dirB/", + }, + [ + 'import { mod } from "@v/deep/mod.ts";', + 'Deno.serve(() => new Response("ok"));', + "", + ].join("\n"), + ); + yield* makeDirectory(join(root, "dirA"), true); + yield* makeDirectory(join(root, "dirB"), true); + const modPath = join(root, "dirB", "mod.ts"); + yield* writeFileString(modPath, "export const mod = 2;\n"); + const warnings: Array<string> = []; + + try { + const binds = yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + // Proves the LONGER key ("@v/deep/") won: the walker followed + // "@v/deep/mod.ts" through dirB and bound the resolved FILE. Had the + // shorter key incorrectly won, the walker would have tried + // "<dirA>/deep/mod.ts" instead (which does not exist). + expect(binds.some((bind) => dockerBindHostPath(bind) === modPath)).toBe(true); + expect(binds.some((bind) => bind.includes(join("dirA", "deep")))).toBe(false); + expect(warnings).toEqual([]); + } finally { + yield* remove(root); + } + }), + )); + + it("no longer applies a scope whose name coincidentally shares a string prefix with the current file's directory (spec-strict scope matching)", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, outputDir, config } = yield* createFunctionProjectWithDenoJson( + { + imports: { "@lib": "../../../scoped-test/fallback-lib.ts" }, + scopes: { + "../hell": { "@lib": "../../../scoped-test/definitely-not-real.ts" }, + }, + }, + ['import { lib } from "@lib";', 'Deno.serve(() => new Response("ok"));', ""].join("\n"), + ); + yield* makeDirectory(join(root, "scoped-test"), true); + yield* writeFileString( + join(root, "scoped-test", "fallback-lib.ts"), + "export const lib = 1;\n", + ); + const warnings: Array<string> = []; + + try { + yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + skipMissingImportMapTargets: true, + }); + + // Scope name "../hell" resolves to ".../functions/hell" — the OLD bare + // `startsWith` rule let that match the entrypoint's OWN directory + // (".../functions/hello") purely as a string prefix ("hello" starts + // with "hell" as characters, not as a path segment). If that scope + // incorrectly applied, "@lib" would resolve to the scoped (nonexistent) + // target and the walker itself would emit a "failed to read file" + // warning for it — distinct from the constant "Skipping missing import + // map target" warning that the independent, unconditional + // target-enumeration walk always emits for that same value regardless + // of whether its scope matches anything. + expect( + warnings.some( + (warning) => warning.includes("failed to read file") && warning.includes("not-real"), + ), + ).toBe(false); + expect( + warnings.some( + (warning) => + warning.includes("Skipping missing import map target") && + warning.includes("not-real"), + ), + ).toBe(true); + } finally { + yield* remove(root); + } + }), + )); + + it("silently drops a trailing-slash directory-shaped specifier instead of crashing", () => + Effect.runPromise( + Effect.gen(function* () { + const { root, functionsDir, functionDir, outputDir, config } = + yield* createHelloFunctionProject( + { "@dir/": "./sub/" }, + 'import "@dir/nested/";\nDeno.serve(() => new Response("ok"));\n', + ); + yield* makeDirectory(join(functionDir, "sub"), true); + const warnings: Array<string> = []; + + try { + yield* buildDockerBinds("test-project", functionsDir, outputDir, config, { + onWarning: warningCollector(warnings), + }); + + expect(warnings.some((warning) => warning.includes("nested"))).toBe(false); + } finally { + yield* remove(root); + } + }), + )); }); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index d6beca0b38..856fc3efd5 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -1,9 +1,9 @@ import { operationDefinitions, SupabaseApiInputError, type ApiClient } from "@supabase/api/effect"; import { randomUUID } from "node:crypto"; -import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { BunPath } from "@effect/platform-bun"; import { fileURLToPath } from "node:url"; -import { Effect, FileSystem, Option } from "effect"; +import { Config, Effect, FileSystem, Option, Schema } from "effect"; +import * as EffectPath from "effect/Path"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -28,6 +28,12 @@ import { runChildProcess, } from "./functions-docker.ts"; import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; + +const { dirname, isAbsolute, join, relative, resolve, sep } = Effect.runSync( + EffectPath.Path.pipe(Effect.provide(BunPath.layer)), +); +const posix = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layerPosix))); +const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import { edgeRuntimeImage, FUNCTIONS_BUNDLER_MUTEX_GROUP, @@ -41,7 +47,11 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "./download.errors.ts"; -import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; +import { + FunctionsApiStatusError, + FunctionsApiTransportError, + FunctionsOperationError, +} from "./functions-api.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; // Go: `utils.DockerDenoDir`/`utils.DockerEszipDir` (`internal/utils/deno.go:34-35`) @@ -452,7 +462,10 @@ function readContentDispositionParam( new RegExp(`(?:^|;)\\s*${paramPattern}=([^;]*)`, "i"), ); if (assignmentMatch === null) { - return Effect.succeed(undefined); + return Effect.gen(function* () { + yield* Effect.void; + return undefined; + }); } const token = assignmentMatch[1]?.trim() ?? ""; if (token.length > 0 && !token.startsWith('"') && !/\s/.test(token)) { @@ -502,7 +515,10 @@ function readFormFieldName( ): Effect.Effect<string | undefined, InvalidFunctionDownloadResponseError> { const contentDisposition = headers["content-disposition"]; if (contentDisposition === undefined) { - return Effect.succeed(undefined); + return Effect.gen(function* () { + yield* Effect.void; + return undefined; + }); } return readContentDispositionParam(contentDisposition, "name"); } @@ -652,32 +668,26 @@ function writeFileWithoutFollowingSymlinks( sourcePath: string, ) { return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; const tempDestination = join(dirname(destination), `.supabase-download-${randomUUID()}.tmp`); - const file = yield* Effect.tryPromise({ - try: () => open(tempDestination, "wx"), - catch: (cause) => - new UnsafeFunctionDownloadPathError({ - message: `failed to create temporary Function file while extracting ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }); - - yield* Effect.tryPromise({ - try: () => file.writeFile(body), - catch: (cause) => - new UnsafeFunctionDownloadPathError({ - message: `failed to write Function file: ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }).pipe(Effect.ensuring(Effect.promise(() => file.close()).pipe(Effect.ignore))); + yield* fs.writeFile(tempDestination, body, { flag: "wx" }).pipe( + Effect.mapError( + (cause) => + new UnsafeFunctionDownloadPathError({ + message: `failed to write Function file: ${sourcePath}: ${cause.message}`, + }), + ), + ); - yield* Effect.tryPromise({ - try: () => rename(tempDestination, destination), - catch: (cause) => - new UnsafeFunctionDownloadPathError({ - message: `failed to move Function file into place: ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - }).pipe( + yield* fs.rename(tempDestination, destination).pipe( + Effect.mapError( + (cause) => + new UnsafeFunctionDownloadPathError({ + message: `failed to move Function file into place: ${sourcePath}: ${cause.message}`, + }), + ), Effect.catch((error) => - Effect.promise(() => rm(tempDestination, { force: true })).pipe( + fs.remove(tempDestination, { force: true }).pipe( Effect.ignore, Effect.andThen(() => Effect.fail(error)), ), @@ -695,17 +705,15 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); if (response.status !== 200) { - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `unexpected list functions status ${response.status}: ${body}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected list functions status ${response.status}: ${body}`, + }); } return yield* Effect.try({ try: () => { - const parsed = JSON.parse(body); + const parsed = decodeJsonText(body); if (!Array.isArray(parsed)) { throw new Error("expected functions list response to be an array"); } @@ -771,23 +779,19 @@ const getRemoteFunction = Effect.fnUntraced(function* ( case 200: break; case 404: - return yield* Effect.fail( - new FunctionDownloadNotFoundError({ - message: `Function ${slug} does not exist on the Supabase project.`, - }), - ); + return yield* new FunctionDownloadNotFoundError({ + message: `Function ${slug} does not exist on the Supabase project.`, + }); default: - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `Failed to download Function ${slug} on the Supabase project: ${body}`, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `Failed to download Function ${slug} on the Supabase project: ${body}`, + }); } return yield* Effect.try({ try: () => { - const parsed = JSON.parse(body); + const parsed = decodeJsonText(body); const entrypointPath = getObjectProperty(parsed, "entrypoint_path"); return typeof entrypointPath === "string" && entrypointPath.length > 0 ? { entrypoint_path: entrypointPath } @@ -821,13 +825,11 @@ const downloadBody = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail( - new FunctionsApiStatusError({ - status: response.status, - message: `Error status ${response.status}: ${body}`, - notFoundIsInvalidInput: true, - }), - ); + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `Error status ${response.status}: ${body}`, + notFoundIsInvalidInput: true, + }); }); // Go: `downloadOne` (`apps/cli-go/internal/functions/download/download.go:218-245`) @@ -870,16 +872,19 @@ const downloadEszipBody = Effect.fnUntraced(function* ( if (response.status !== 200) { const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); + return yield* new FunctionsOperationError({ + message: `Error status ${response.status}: ${body}`, + }); } return new Uint8Array( yield* response.arrayBuffer.pipe( Effect.mapError( (cause) => - new Error( - `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, - ), + new FunctionsOperationError({ + message: `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), ), ), ); @@ -1007,6 +1012,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( projectRef: string, slug: string, ) { + const fs = yield* FileSystem.FileSystem; const output = yield* Output; const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); const styleAqua = dependencies.styleAqua ?? ((text: string) => text); @@ -1022,20 +1028,26 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const eszip = yield* downloadEszipBody(dependencies.api, projectRef, slug); const tempDir = join(dependencies.projectRoot, "supabase", ".temp"); - yield* Effect.tryPromise({ - try: () => mkdir(tempDir, { recursive: true }), - catch: (cause) => - new Error(`failed to mkdir: ${cause instanceof Error ? cause.message : String(cause)}`), - }); + yield* fs.makeDirectory(tempDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: `failed to mkdir: ${cause.message}`, + cause, + }), + ), + ); const eszipFileName = `output_${slug}.eszip`; const eszipPath = join(tempDir, eszipFileName); - yield* Effect.tryPromise({ - try: () => writeFile(eszipPath, eszip), - catch: (cause) => - new Error( - `failed to download file: ${cause instanceof Error ? cause.message : String(cause)}`, - ), - }); + yield* fs.writeFile(eszipPath, eszip).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: `failed to download file: ${cause.message}`, + cause, + }), + ), + ); // Go: the `defer fsys.Remove(eszipPath)` cleanup is registered right after // the write and covers the whole of `extractOne`, including the container @@ -1057,10 +1069,9 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; const cleanupEszip = debugEnabled ? Effect.void - : Effect.tryPromise({ - try: () => rm(eszipPath, { force: true }), - catch: (cause) => (cause instanceof Error ? cause.message : String(cause)), - }).pipe(Effect.catch((message) => output.raw(`${message}\n`, "stderr"))); + : fs + .remove(eszipPath, { force: true }) + .pipe(Effect.catch((cause) => output.raw(`${cause.message}\n`, "stderr"))); const { projectId, denoVersion, image, projectEnvValues } = edgeRuntimeImage; const functionsDir = resolve(dependencies.projectRoot, "supabase", "functions"); @@ -1081,7 +1092,7 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( envOverride: projectEnvValues === undefined ? undefined - : legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), + : yield* legacyViperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, }); @@ -1091,9 +1102,11 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( yield* ensureDockerNetwork(networkMode, projectId).pipe( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId).pipe( - Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), - ); + yield* ensureDockerNamedVolume( + localDockerId("edge_runtime", projectId), + projectId, + projectEnvValues, + ).pipe(Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua))); // Bind order matches `extractOne` (`download.go:260-266`) exactly. Go's // `DockerStart` drops the named-volume bind entirely on Bitbucket @@ -1102,8 +1115,12 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( // implicitly create the named volume, which Bitbucket's restricted Docker // environment doesn't allow, same carve-out as `deploy.ts`'s // `buildDockerBinds`. + const bitbucketCloneDir = + projectEnvValues === undefined + ? Option.getOrUndefined(yield* Config.option(Config.string("BITBUCKET_CLONE_DIR"))) + : yield* legacyViperEnvStringWithProjectFallback("BITBUCKET_CLONE_DIR", projectEnvValues); const binds = [ - ...(process.env["BITBUCKET_CLONE_DIR"] === undefined + ...(bitbucketCloneDir === undefined || bitbucketCloneDir.length === 0 ? [`${localDockerId("edge_runtime", projectId)}:/root/.cache/deno:rw`] : []), `${hostEszipPath}:${dockerEszipPath}:ro`, @@ -1153,11 +1170,10 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( .some((line) => line.trim().toLowerCase() === "invalid eszip v2"); const suggestion = (invalidEszipV2 ? suggestDenoV2(styleEmphasis) : "") + suggestLegacyBundle(slug, styleAqua); - return yield* Effect.fail( - Object.assign(new Error(`error running container: exit ${result.exitCode}`), { - suggestion, - }), - ); + return yield* new FunctionsOperationError({ + message: `error running container: exit ${result.exitCode}`, + suggestion, + }); } // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts index 25d1357bd1..dbff042991 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -53,3 +53,19 @@ export class FunctionsApiTransportError extends Data.TaggedError("FunctionsApiTr return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; } } + +/** + * A typed failure for function operations that do not have an HTTP response. + * Keeping these failures tagged preserves the originating cause without + * widening Effect error channels to the global `Error` type. + */ +export class FunctionsOperationError extends Data.TaggedError("FunctionsOperationError")<{ + readonly message: string; + readonly cause?: unknown; + readonly causes?: ReadonlyArray<unknown>; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts index 7202e6af6a..9a00adb0ea 100644 --- a/apps/cli/src/shared/functions/functions-config.ts +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -1,8 +1,11 @@ -import { basename } from "node:path"; +import { BunPath } from "@effect/platform-bun"; import { Effect, type FileSystem, type Path } from "effect"; +import * as EffectPath from "effect/Path"; import { loadProjectConfig, type LoadedProjectConfig } from "@supabase/config"; import { normalizeProjectId } from "./functions-docker.ts"; +const { basename } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); + /** * Everything the native `functions` Docker paths (`deploy`/`download`/`serve`) * need from project config resolution, unified across both shells. In the diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 999fc633d1..4311d75602 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -4,11 +4,15 @@ // (a different family, reaching in for the generic `isUserDefinedDockerNetwork` // predicate), both of which already imported these primitives from `deploy.ts` // before this file existed. -import { resolve } from "node:path"; -import { Effect, Stream } from "effect"; +import { BunPath } from "@effect/platform-bun"; +import { Config, Effect, Option, Stream } from "effect"; +import * as EffectPath from "effect/Path"; import { ChildProcessSpawner } from "effect/unstable/process"; import { spawnContainerCli } from "../../legacy/shared/legacy-container-cli.ts"; import { legacyMakeDockerImageResolver } from "../../legacy/shared/legacy-docker-image-resolve.ts"; +import { FunctionsOperationError } from "./functions-api.errors.ts"; + +const { resolve } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); const INVALID_PROJECT_ID = /[^a-zA-Z0-9_.-]+/g; const MAX_PROJECT_ID_LENGTH = 40; @@ -91,15 +95,23 @@ export function toDockerPath(hostPath: string) { */ export function containerArchiveBytes( files: Readonly<Record<string, string>>, -): Promise<Uint8Array> { - return new Bun.Archive( - Object.fromEntries( - Object.entries(files).map(([containerPath, content]) => [ - containerPath.replace(/^\/+/, ""), - content, - ]), - ), - ).bytes(); +): Effect.Effect<Uint8Array, FunctionsOperationError> { + return Effect.tryPromise({ + try: () => + new Bun.Archive( + Object.fromEntries( + Object.entries(files).map(([containerPath, content]) => [ + containerPath.replace(/^\/+/, ""), + content, + ]), + ), + ).bytes(), + catch: (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); } export interface FunctionsDockerRunSpec { @@ -162,10 +174,10 @@ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array // arrives — Go's `DockerStreamLogs`/`DockerRunOnceWithConfig` copy a // container's log stream live while it runs, rather than buffering the whole // thing until exit. -function collectByteStream( - stream: Stream.Stream<Uint8Array, unknown>, +function collectByteStream<E>( + stream: Stream.Stream<Uint8Array, E>, onChunk?: (chunk: string) => Effect.Effect<void>, -): Effect.Effect<string, unknown> { +): Effect.Effect<string, FunctionsOperationError> { return Effect.suspend(() => { const decoder = new TextDecoder(); let text = ""; @@ -178,6 +190,13 @@ function collectByteStream( ).pipe( Effect.flatMap(() => append(decoder.decode())), Effect.map(() => text), + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), ); }); } @@ -272,7 +291,7 @@ export const ensureDockerNetwork = Effect.fnUntraced(function* ( const inspect = yield* runChildProcess("docker", ["network", "inspect", networkMode], { stdout: "ignore", stderr: "ignore", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + }).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, stdout: "", stderr: "" }))); if (inspect.exitCode === 0) { return; } @@ -295,15 +314,22 @@ export const ensureDockerNetwork = Effect.fnUntraced(function* ( }, ); if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker network: ${networkMode}`)); + return yield* new FunctionsOperationError({ + message: `failed to create docker network: ${networkMode}`, + }); } }); export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( volumeName: string, projectId: string, + projectEnvValues?: Readonly<Record<string, string>>, ) { - if (process.env["BITBUCKET_CLONE_DIR"] !== undefined) { + const bitbucketCloneDir = + projectEnvValues === undefined + ? Option.getOrUndefined(yield* Config.option(Config.string("BITBUCKET_CLONE_DIR"))) + : projectEnvValues.BITBUCKET_CLONE_DIR; + if (bitbucketCloneDir !== undefined && bitbucketCloneDir.length > 0) { return; } @@ -325,7 +351,9 @@ export const ensureDockerNamedVolume = Effect.fnUntraced(function* ( }, ); if (create.exitCode !== 0 && !create.stderr.includes("already exists")) { - return yield* Effect.fail(new Error(`failed to create docker volume: ${volumeName}`)); + return yield* new FunctionsOperationError({ + message: `failed to create docker volume: ${volumeName}`, + }); } }); @@ -333,7 +361,7 @@ export const isDockerRunning = Effect.fnUntraced(function* () { const result = yield* runChildProcess("docker", ["info"], { stdout: "ignore", stderr: "ignore", - }).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + }).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, stdout: "", stderr: "" }))); return result.exitCode === 0; }); @@ -349,7 +377,7 @@ export const isDockerRunning = Effect.fnUntraced(function* () { export function resolveEdgeRuntimeVersion( denoVersion: number | undefined, defaultVersion: string, -): Effect.Effect<string, Error> { +): Effect.Effect<string, FunctionsOperationError> { if (denoVersion === undefined || denoVersion === 2) { return Effect.succeed(defaultVersion); } @@ -357,7 +385,9 @@ export function resolveEdgeRuntimeVersion( return Effect.succeed(DENO1_EDGE_RUNTIME_VERSION); } return Effect.fail( - new Error(`Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`), + new FunctionsOperationError({ + message: `Failed reading config: Invalid edge_runtime.deno_version: ${denoVersion}.`, + }), ); } diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 947c1a001b..43330b1e35 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -1,14 +1,16 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, Sink, Stream } from "effect"; +import { ConfigProvider, Deferred, Effect, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { buildFunctionsDockerRunArgs, containerArchiveBytes, + ensureDockerNamedVolume, localDockerId, resolveDockerNetworkMode, runChildProcess, } from "./functions-docker.ts"; +import { makeLegacyViperEnvLayer } from "../legacy/legacy-viper-env.ts"; /** * A `ChildProcessSpawner` layer whose handle emits exactly the given raw @@ -208,14 +210,20 @@ describe("containerArchiveBytes", () => { return entries; } - it("strips leading slashes into root-relative tar entries with the contractual 0644 mode", async () => { - const archive = await containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" }); - // The 0644 mode is contractual — a Bun default change must fail here, not as a - // runtime permission error inside the container. - expect(tarRegularFileEntries(archive)).toEqual([["root/index.ts", 0o644]]); - const files = await new Bun.Archive(archive).files(); - expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n"); - }); + it("strips leading slashes into root-relative tar entries with the contractual 0644 mode", () => + Effect.runPromise( + Effect.gen(function* () { + const archive = yield* containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" }); + // The 0644 mode is contractual — a Bun default change must fail here, not as a + // runtime permission error inside the container. + expect(tarRegularFileEntries(archive)).toEqual([["root/index.ts", 0o644]]); + const files = yield* Effect.promise(() => new Bun.Archive(archive).files()); + const entry = files.get("root/index.ts"); + expect(entry === undefined ? undefined : yield* Effect.promise(() => entry.text())).toBe( + "export const x = 1;\n", + ); + }), + )); }); describe("resolveDockerNetworkMode", () => { @@ -274,6 +282,24 @@ describe("resolveDockerNetworkMode", () => { }); }); +describe("ensureDockerNamedVolume", () => { + it.effect("skips creation when Bitbucket is supplied by project dotenv values", () => + ensureDockerNamedVolume("supabase_edge_runtime_project", "project", { + BITBUCKET_CLONE_DIR: "/opt/bitbucket", + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected Docker volume creation")), + ), + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env: {}, preserveEmptyStrings: true })), + ), + ), + ), + ); +}); + describe("runChildProcess", () => { it.effect( "tees a multi-byte UTF-8 character split across a chunk boundary, decoding it correctly in both the live tee and the accumulated stdout, and never tees an empty string", diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 63f740c849..1f17f45259 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -1,6 +1,4 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import { Effect } from "effect"; +import { Effect, FileSystem, Path } from "effect"; import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; const functionSlugPattern = /^[A-Za-z][A-Za-z0-9_-]*$/; @@ -57,11 +55,11 @@ export function edgeRuntimeImage(tag: string): string { * `readFile` -> `trim` -> fallback pipeline. */ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabaseDir: string) { - return yield* Effect.tryPromise(() => - readFile(join(supabaseDir, ".temp", "edge-runtime-version"), "utf8"), - ).pipe( + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(supabaseDir, ".temp", "edge-runtime-version")).pipe( Effect.map((version) => version.trim()), - Effect.catch(() => Effect.succeed("")), + Effect.orElseSucceed(() => ""), Effect.map((version) => version || DEFAULT_EDGE_RUNTIME_TAG), ); }); diff --git a/apps/cli/src/shared/functions/serve-main-bundler.ts b/apps/cli/src/shared/functions/serve-main-bundler.ts index e6960ff28c..b4ca28482b 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from "node:url"; import { build } from "esbuild"; +import { Effect } from "effect"; +import { FunctionsOperationError } from "./functions-api.errors.ts"; /** * Absolute path to the edge-runtime bootstrap template. The template runs verbatim @@ -20,21 +22,31 @@ const serveMainEntrypoint = fileURLToPath(new URL("./serve.main.ts", import.meta * `platform: "browser"` selects `jose`'s Web Crypto build, which runs under the * edge-runtime's Deno. `Deno` and `EdgeRuntime` are left as free globals. */ -export async function bundleServeMainTemplate(): Promise<string> { - const result = await build({ - entryPoints: [serveMainEntrypoint], - bundle: true, - format: "esm", - platform: "browser", - minify: true, - write: false, - legalComments: "none", - logLevel: "silent", +export const bundleServeMainTemplate = Effect.fnUntraced(function* () { + const result = yield* Effect.tryPromise({ + try: () => + build({ + entryPoints: [serveMainEntrypoint], + bundle: true, + format: "esm", + platform: "browser", + minify: true, + write: false, + legalComments: "none", + logLevel: "silent", + }), + catch: (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), }); const output = result.outputFiles[0]?.text; if (output === undefined) { - throw new Error("esbuild produced no output for the functions serve runtime template"); + return yield* new FunctionsOperationError({ + message: "esbuild produced no output for the functions serve runtime template", + }); } return output; -} +}); diff --git a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts index b2677be895..240ad219b5 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.unit.test.ts @@ -1,24 +1,33 @@ import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; describe("bundleServeMainTemplate", () => { - it("produces a self-contained runtime template with no remote import specifiers", async () => { - const bundled = await bundleServeMainTemplate(); + it("produces a self-contained runtime template with no remote import specifiers", () => + Effect.runPromise( + Effect.gen(function* () { + const bundled = yield* bundleServeMainTemplate(); - // The offline failure (#45570) was caused by these being resolved over the - // network on every container start. They must be inlined into the bundle. - expect(bundled).not.toContain("https://"); - expect(bundled).not.toContain("jsr:"); - expect(bundled).not.toMatch(/from\s*["']jose["']/); - }); + // The offline failure (#45570) was caused by these being resolved over the + // network on every container start. They must be inlined into the bundle. + // Effect's bundled diagnostics include documentation URLs as plain + // strings; only import specifiers must stay network-independent. + expect(bundled).not.toMatch(/(?:from|import\s*\()\s*["']https?:\/\//); + expect(bundled).not.toContain("jsr:"); + expect(bundled).not.toMatch(/from\s*["']jose["']/); + }), + )); - it("preserves the template's Deno.serve entrypoint and inlines jose", async () => { - const bundled = await bundleServeMainTemplate(); + it("preserves the template's Deno.serve entrypoint and inlines jose", () => + Effect.runPromise( + Effect.gen(function* () { + const bundled = yield* bundleServeMainTemplate(); - // Template body survives bundling (Deno global left as a free reference). - expect(bundled).toContain("Deno.serve"); - // jose is inlined, so the bundle is materially larger than the ~12KB template. - expect(bundled.length).toBeGreaterThan(20_000); - }); + // Template body survives bundling (Deno global left as a free reference). + expect(bundled).toContain("Deno.serve"); + // jose is inlined, so the bundle is materially larger than the ~12KB template. + expect(bundled.length).toBeGreaterThan(20_000); + }), + )); }); diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index 6945a2d8f7..bcc9e5b7e2 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -1,9 +1,14 @@ -import { execSync, spawnSync } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { BunFileSystem, BunHttpClient, BunPath, BunServices } from "@effect/platform-bun"; import { describe, expect, test } from "vitest"; +import { Duration, Effect, FileSystem, Schedule, Stream } from "effect"; +import * as EffectPath from "effect/Path"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HttpClient } from "effect/unstable/http"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import type { PlatformError } from "effect/PlatformError"; import { LEGACY_START_KONG_YML_TEMPLATE } from "../../legacy/commands/start/templates/kong.yml.ts"; import { LEGACY_EDGE_RUNTIME_IMAGE } from "../../legacy/shared/legacy-edge-runtime-image.ts"; @@ -11,6 +16,93 @@ import { ensureImage, resolveDeadline } from "../../../tests/helpers/docker-imag import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; +const { join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); + +const withFileSystem = <A>( + effect: Effect.Effect<A, PlatformError, FileSystem.FileSystem>, +): Effect.Effect<A, PlatformError, never> => effect.pipe(Effect.provide(BunFileSystem.layer)); + +const makeDirectory = (path: string, recursive = false) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive }); + }), + ); +const makeTempDirectory = (prefix: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ directory: tmpdir(), prefix }); + }), + ); +const remove = (path: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true, force: true }); + }), + ); +const writeFileString = (path: string, contents: string) => + withFileSystem( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }), + ); + +interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} + +function runCommand( + command: string | ReadonlyArray<string>, + argsOrOptions: + | ReadonlyArray<string> + | { readonly ignoreOutput?: boolean; readonly stdio?: string } = [], + maybeOptions: { + readonly ignoreOutput?: boolean; + readonly stdio?: string; + } = {}, +): Effect.Effect<CommandResult, PlatformError, never> { + const isArgs = (value: typeof argsOrOptions): value is ReadonlyArray<string> => + Array.isArray(value); + const args = isArgs(argsOrOptions) ? [...argsOrOptions] : []; + const options = isArgs(argsOrOptions) ? maybeOptions : argsOrOptions; + const cmd = typeof command === "string" ? [command, ...args] : [...command]; + const executable = cmd[0]; + if (executable === undefined) { + return Effect.die("command cannot be empty"); + } + const captureOutput = options.ignoreOutput !== true && options.stdio !== "ignore"; + return Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(executable, cmd.slice(1), { + stdout: captureOutput ? "pipe" : "ignore", + stderr: captureOutput ? "pipe" : "ignore", + }), + ); + const result = yield* Effect.all( + { + status: handle.exitCode, + stdout: captureOutput + ? Stream.mkString(Stream.decodeText(handle.stdout)) + : Effect.succeed(""), + stderr: captureOutput + ? Stream.mkString(Stream.decodeText(handle.stderr)) + : Effect.succeed(""), + }, + { concurrency: "unbounded" }, + ); + return result; + }), + ).pipe(Effect.provide(BunServices.layer)); +} + /** * Regression guard for supabase/supabase#45570: the edge-runtime worker bootstrap * template must boot with **no network access**. Before bundling, the template @@ -24,17 +116,12 @@ import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; * variable (a control run of the unbundled template fails here with a DNS error). */ -function hasDocker(): boolean { - try { - execSync("docker info", { stdio: "ignore" }); - return true; - } catch { - return false; - } -} - -const dockerAvailable = hasDocker(); -const SERVE_OFFLINE_STARTUP_TIMEOUT_MS = 60_000; +const dockerAvailable = await Effect.runPromise( + runCommand(["docker", "info"], { ignoreOutput: true }).pipe( + Effect.map((result) => result.status === 0), + Effect.orElseSucceed(() => false), + ), +); // Cold-cache image resolution (up to one shared 90s resolveDeadline budget) // runs inside the test body, ahead of the 60s startup wait — the test budget // must cover both stacked, or a healthy near-cap pull trips vitest first. @@ -47,17 +134,23 @@ const AUTH_FUNCTIONS_CONFIG = JSON.stringify({ verifyJWT: true, }, }); +const MALFORMED_ENV_FUNCTIONS_CONFIG = JSON.stringify({ + test: { + entrypointPath: "/tmp/test/index.ts", + importMapPath: "", + verifyJWT: true, + env: 1, + }, +}); const KONG_FUNCTIONS_CONFIG = JSON.stringify({ test: { entrypointPath: "/app/functions/custom/index.ts", importMapPath: "", - staticFiles: [], verifyJWT: true, }, custom: { entrypointPath: "/app/functions/custom/index.ts", - importMapPath: "", - staticFiles: [], + importMapPath: "/app/import_map.json", verifyJWT: false, env: { SHARED: "function", @@ -120,12 +213,43 @@ const authFailureCases = [ }, ]; -function containerLogs(container: string): string { - const result = spawnSync("docker", ["logs", container], { encoding: "utf8" }); - return `${result.stdout ?? ""}\n${result.stderr ?? ""}`; +function containerLogs(container: string): Effect.Effect<string, PlatformError, never> { + return runCommand(["docker", "logs", container]).pipe( + Effect.map((result) => `${result.stdout}\n${result.stderr}`), + ); +} + +const readinessSchedule = Schedule.recurs(240).pipe( + Schedule.addDelay(() => Effect.succeed(Duration.millis(250))), +); + +function waitForLogs(container: string, matches: RegExp): Effect.Effect<string, string> { + return Effect.gen(function* () { + const logs = yield* containerLogs(container).pipe(Effect.mapError(String)); + return matches.test(logs) ? logs : yield* Effect.fail("container is not ready"); + }).pipe(Effect.retry(readinessSchedule)); +} + +function httpGet(url: string, headers?: Readonly<Record<string, string>>) { + return HttpClient.execute( + HttpClientRequest.get(url).pipe( + headers === undefined ? (request) => request : HttpClientRequest.setHeaders(headers), + ), + ).pipe(Effect.provide(BunHttpClient.layer)); +} + +function waitForStatus(url: string, expectedStatus: number) { + return httpGet(url).pipe( + Effect.tap((response) => response.text.pipe(Effect.ignore)), + Effect.filterOrFail( + (response) => response.status === expectedStatus, + () => "container is not ready", + ), + Effect.retry(readinessSchedule), + ); } -async function writeKongConfig(dir: string, edgeRuntimeContainer: string) { +function writeKongConfig(dir: string, edgeRuntimeContainer: string) { // Was: read straight from apps/cli-go/internal/start/templates/kong.yml. That // package was deleted outright (CLI-1966; unreachable from the TS CLI, directly // or indirectly), so this now uses the TS transcription of the same template @@ -137,309 +261,357 @@ async function writeKongConfig(dir: string, edgeRuntimeContainer: string) { .replaceAll("{{ .BearerToken }}", "$((headers.authorization or headers.apikey))") .replaceAll("{{ .QueryToken }}", "$((query_params.apikey))") .replace(/{{ \.[A-Za-z]+ }}/g, "unused"); - await writeFile(join(dir, "kong.yml"), config); + return writeFileString(join(dir, "kong.yml"), config); } describe("functions serve runtime template (offline)", () => { test.skipIf(!dockerAvailable)( "boots under edge-runtime with networking disabled and fetches nothing remote", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, - async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); - const dir = await mkdtemp(join(tmpdir(), "supabase-serve-offline-e2e-")); - const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; - try { - await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); - - const run = spawnSync( - "docker", - [ - "run", - "-d", - "--name", - container, - "--network", - "none", - "-e", - "SUPABASE_INTERNAL_HOST_PORT=8081", - "-e", - "SUPABASE_INTERNAL_JWT_SECRET=offline-e2e", - "-e", - "SUPABASE_URL=http://127.0.0.1:54321", - "-e", - "SUPABASE_INTERNAL_FUNCTIONS_CONFIG={}", - "-e", - "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", - "-v", - `${dir}:/app:ro`, - "--entrypoint", - "edge-runtime", - runtimeImage, - "start", - "--main-service=/app", - "--port=8081", - ], - { encoding: "utf8" }, - ); - expect(run.status, run.stderr).toBe(0); - - const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; - let logs = ""; - while (Date.now() < deadline) { - logs = containerLogs(container); - if (/Serving functions on/.test(logs) || /worker boot error/i.test(logs)) { - break; + () => + Effect.runPromise( + Effect.gen(function* () { + const runtimeImage = yield* Effect.tryPromise({ + try: () => ensureImage(LEGACY_EDGE_RUNTIME_IMAGE), + catch: (cause) => String(cause), + }); + const dir = yield* makeTempDirectory("supabase-serve-offline-e2e-"); + const container = `supabase-serve-offline-e2e-${process.pid.toString()}`; + try { + yield* writeFileString(join(dir, "index.ts"), yield* bundleServeMainTemplate()); + + const run = yield* runCommand("docker", [ + "run", + "-d", + "--name", + container, + "--network", + "none", + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=offline-e2e", + "-e", + "SUPABASE_URL=http://127.0.0.1:54321", + "-e", + "SUPABASE_INTERNAL_FUNCTIONS_CONFIG={}", + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + runtimeImage, + "start", + "--main-service=/app", + "--port=8081", + ]); + expect(run.status, run.stderr).toBe(0); + + const logs = yield* waitForLogs(container, /Serving functions on|worker boot error/i); + + // The template's own onListen message — proves the bundled worker booted. + expect(logs).toMatch(/Serving functions on/); + // No remote module resolution occurred (the #45570 failure mode). + expect(logs).not.toMatch(/deno\.land|jsr\.io/); + expect(logs).not.toMatch(/dns error|name resolution|worker boot error/i); + } finally { + yield* runCommand("docker", ["rm", "-f", container], { stdio: "ignore" }); + yield* remove(dir); } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - - // The template's own onListen message — proves the bundled worker booted. - expect(logs).toMatch(/Serving functions on/); - // No remote module resolution occurred (the #45570 failure mode). - expect(logs).not.toMatch(/deno\.land|jsr\.io/); - expect(logs).not.toMatch(/dns error|name resolution|worker boot error/i); - } finally { - spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" }); - await rm(dir, { recursive: true, force: true }); - } - }, + }), + ), ); test.skipIf(!dockerAvailable)( "returns canonical JWT auth failures", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, - async () => { - const runtimeImage = await ensureImage(LEGACY_EDGE_RUNTIME_IMAGE); - const dir = await mkdtemp(join(tmpdir(), "supabase-serve-auth-e2e-")); - const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; - try { - await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); - - const run = spawnSync( - "docker", - [ - "run", - "-d", - "--name", - container, - "-p", - "127.0.0.1::8081", - "-e", - "SUPABASE_INTERNAL_HOST_PORT=8081", - "-e", - "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", - "-e", - "SUPABASE_URL=http://127.0.0.1:54321", - "-e", - `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${AUTH_FUNCTIONS_CONFIG}`, - "-e", - "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", - "-e", - 'SUPABASE_JWKS={"keys":[]}', - "-v", - `${dir}:/app:ro`, - "--entrypoint", - "edge-runtime", - runtimeImage, - "start", - "--main-service=/app", - "--port=8081", - ], - { encoding: "utf8" }, - ); - expect(run.status, run.stderr).toBe(0); - - const portResult = spawnSync("docker", ["port", container, "8081/tcp"], { - encoding: "utf8", - }); - expect(portResult.status, portResult.stderr).toBe(0); - const port = Number(portResult.stdout.trim().split(":").at(-1)); - expect(port).toBeGreaterThan(0); - const url = `http://127.0.0.1:${port}/test`; - - const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; - let ready = false; - while (Date.now() < deadline) { + () => + Effect.runPromise( + Effect.gen(function* () { + const runtimeImage = yield* Effect.tryPromise({ + try: () => ensureImage(LEGACY_EDGE_RUNTIME_IMAGE), + catch: () => "image unavailable", + }); + const dir = yield* makeTempDirectory("supabase-serve-auth-e2e-"); + const container = `supabase-serve-auth-e2e-${process.pid.toString()}`; try { - const response = await fetch(url); - if (response.status === 401) { - ready = true; - break; + yield* writeFileString(join(dir, "index.ts"), yield* bundleServeMainTemplate()); + + const run = yield* runCommand("docker", [ + "run", + "-d", + "--name", + container, + "-p", + "127.0.0.1::8081", + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", + "-e", + "SUPABASE_URL=http://127.0.0.1:54321", + "-e", + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${AUTH_FUNCTIONS_CONFIG}`, + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-e", + 'SUPABASE_JWKS={"keys":[]}', + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + runtimeImage, + "start", + "--main-service=/app", + "--port=8081", + ]); + expect(run.status, run.stderr).toBe(0); + + const portResult = yield* runCommand("docker", ["port", container, "8081/tcp"]); + expect(portResult.status, portResult.stderr).toBe(0); + const port = Number(portResult.stdout.trim().split(":").at(-1)); + expect(port).toBeGreaterThan(0); + const url = `http://127.0.0.1:${port}/test`; + + yield* waitForStatus(url, 401).pipe( + Effect.catch(() => + Effect.gen(function* () { + return yield* Effect.fail(yield* containerLogs(container)); + }), + ), + ); + + for (const { name, authorization, code, message } of authFailureCases) { + const response = yield* httpGet( + url, + authorization === undefined ? undefined : { authorization }, + ); + expect(response.status, name).toBe(401); + expect(response.headers["content-type"], name).toContain("application/json"); + expect(response.headers["sb-error-code"], name).toBe(code); + expect(yield* response.json, name).toEqual({ code, message, msg: message }); } - } catch {} - await new Promise((resolve) => setTimeout(resolve, 250)); - } - expect(ready, containerLogs(container)).toBe(true); - - for (const { name, authorization, code, message } of authFailureCases) { - const response = await fetch(url, { - headers: authorization === undefined ? undefined : { authorization }, + + const logs = yield* containerLogs(container); + expect(logs).not.toContain("not-a-jwt"); + } finally { + yield* runCommand("docker", ["rm", "-f", container], { stdio: "ignore" }); + yield* remove(dir); + } + }), + ), + ); + + test.skipIf(!dockerAvailable)( + "rejects function configs with malformed environment values", + { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, + () => + Effect.runPromise( + Effect.gen(function* () { + const runtimeImage = yield* Effect.tryPromise({ + try: () => ensureImage(LEGACY_EDGE_RUNTIME_IMAGE), + catch: () => "image unavailable", }); - expect(response.status, name).toBe(401); - expect(response.headers.get("content-type"), name).toContain("application/json"); - expect(response.headers.get("sb-error-code"), name).toBe(code); - expect(await response.json(), name).toEqual({ code, message, msg: message }); - } - } finally { - spawnSync("docker", ["rm", "-f", container], { stdio: "ignore" }); - await rm(dir, { recursive: true, force: true }); - } - }, + const dir = yield* makeTempDirectory("supabase-serve-invalid-config-e2e-"); + const container = `supabase-serve-invalid-config-e2e-${process.pid.toString()}`; + try { + yield* writeFileString(join(dir, "index.ts"), yield* bundleServeMainTemplate()); + + const run = yield* runCommand("docker", [ + "run", + "-d", + "--name", + container, + "--network", + "none", + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=invalid-config-e2e", + "-e", + "SUPABASE_URL=http://127.0.0.1:54321", + "-e", + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${MALFORMED_ENV_FUNCTIONS_CONFIG}`, + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + runtimeImage, + "start", + "--main-service=/app", + "--port=8081", + ]); + expect(run.status, run.stderr).toBe(0); + + const logs = yield* waitForLogs( + container, + /Serving functions on|Failed to parse functions config|functions config has an invalid shape/i, + ); + expect(logs).toMatch( + /Failed to parse functions config|functions config has an invalid shape/i, + ); + expect(logs).not.toContain("Serving functions on"); + } finally { + yield* runCommand("docker", ["rm", "-f", container], { stdio: "ignore" }); + yield* remove(dir); + } + }), + ), ); test.skipIf(!dockerAvailable)( "preserves function env and CORS headers and exposes JWT errors through Kong", { timeout: SERVE_OFFLINE_TEST_TIMEOUT_MS }, - async () => { - const imageDeadline = resolveDeadline(); - const [runtimeImage, kongImage] = await Promise.all([ - ensureImage(LEGACY_EDGE_RUNTIME_IMAGE, imageDeadline), - ensureImage(dockerfileServiceImage("kong"), imageDeadline), - ]); - const dir = await mkdtemp(join(tmpdir(), "supabase-serve-kong-e2e-")); - const network = `supabase-serve-kong-e2e-${process.pid.toString()}`; - const runtimeContainer = `${network}-runtime`; - const kongContainer = `${network}-kong`; - try { - await writeFile(join(dir, "index.ts"), await bundleServeMainTemplate()); - await mkdir(join(dir, "functions", "custom"), { recursive: true }); - await writeFile(join(dir, "functions", "custom", "index.ts"), CUSTOM_FUNCTION); - await writeKongConfig(dir, runtimeContainer); - - const createNetwork = spawnSync("docker", ["network", "create", network], { - encoding: "utf8", - }); - expect(createNetwork.status, createNetwork.stderr).toBe(0); - - const runRuntime = spawnSync( - "docker", - [ - "run", - "-d", - "--name", - runtimeContainer, - "--network", - network, - "-e", - "SUPABASE_INTERNAL_HOST_PORT=8081", - "-e", - "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", - "-e", - `SUPABASE_URL=http://${kongContainer}:8000`, - "-e", - `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${KONG_FUNCTIONS_CONFIG}`, - "-e", - "SUPABASE_INTERNAL_DEBUG=true", - "-e", - "SHARED=shared", - "-e", - "GLOBAL_ONLY=global", - "-e", - "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", - "-e", - 'SUPABASE_JWKS={"keys":[]}', - "-v", - `${dir}:/app:ro`, - "--entrypoint", - "edge-runtime", - runtimeImage, - "start", - "--main-service=/app", - "--port=8081", - ], - { encoding: "utf8" }, - ); - expect(runRuntime.status, runRuntime.stderr).toBe(0); - - const runKong = spawnSync( - "docker", - [ - "run", - "-d", - "--name", - kongContainer, - "--network", - network, - "-p", - "127.0.0.1::8000", - "-e", - "KONG_DATABASE=off", - "-e", - "KONG_DECLARATIVE_CONFIG=/home/kong/kong.yml", - "-e", - "KONG_PLUGINS=request-transformer,cors", - "-e", - "KONG_NGINX_WORKER_PROCESSES=1", - "-v", - `${join(dir, "kong.yml")}:/home/kong/kong.yml:ro`, - kongImage, - "kong", - "docker-start", - ], - { encoding: "utf8" }, - ); - expect(runKong.status, runKong.stderr).toBe(0); - - const portResult = spawnSync("docker", ["port", kongContainer, "8000/tcp"], { - encoding: "utf8", - }); - expect(portResult.status, portResult.stderr).toBe(0); - const port = Number(portResult.stdout.trim().split(":").at(-1)); - expect(port).toBeGreaterThan(0); - const functionsUrl = `http://127.0.0.1:${port}/functions/v1`; - const authUrl = `${functionsUrl}/test`; - - const deadline = Date.now() + SERVE_OFFLINE_STARTUP_TIMEOUT_MS; - let ready = false; - while (Date.now() < deadline) { + () => + Effect.runPromise( + Effect.gen(function* () { + const imageDeadline = resolveDeadline(); + const [runtimeImage, kongImage] = yield* Effect.all( + [ + Effect.tryPromise({ + try: () => ensureImage(LEGACY_EDGE_RUNTIME_IMAGE, imageDeadline), + catch: () => "image unavailable", + }), + Effect.tryPromise({ + try: () => ensureImage(dockerfileServiceImage("kong"), imageDeadline), + catch: () => "image unavailable", + }), + ], + { concurrency: "unbounded" }, + ); + const dir = yield* makeTempDirectory("supabase-serve-kong-e2e-"); + const network = `supabase-serve-kong-e2e-${process.pid.toString()}`; + const runtimeContainer = `${network}-runtime`; + const kongContainer = `${network}-kong`; try { - const response = await fetch(authUrl); - if (response.status === 401) { - ready = true; - break; - } - } catch {} - await new Promise((resolve) => setTimeout(resolve, 250)); - } - expect(ready, `${containerLogs(kongContainer)}\n${containerLogs(runtimeContainer)}`).toBe( - true, - ); - - const customResponse = await fetch(`${functionsUrl}/custom`, { - headers: { Origin: "http://localhost:3000" }, - }); - expect(customResponse.status).toBe(200); - expect(customResponse.headers.get("x-custom-id")).toBe("abc123"); - expect(customResponse.headers.get("x-shared")).toBe("function"); - expect(customResponse.headers.get("x-function-only")).toBe("function"); - expect(customResponse.headers.get("x-global-only")).toBe("global"); - expect(customResponse.headers.get("access-control-expose-headers")?.toLowerCase()).toBe( - "x-custom-id", - ); - const runtimeLogs = containerLogs(runtimeContainer); - expect(runtimeLogs).toContain("Functions config:"); - expect(runtimeLogs).toContain('"custom"'); - expect(runtimeLogs).not.toContain('"env"'); - expect(runtimeLogs).not.toContain("must-not-appear-in-debug-logs"); - - const authResponse = await fetch(authUrl, { - headers: { Origin: "http://localhost:3000" }, - }); - expect(authResponse.status).toBe(401); - expect(authResponse.headers.get("sb-error-code")).toBe("UNAUTHORIZED_NO_AUTH_HEADER"); - expect(authResponse.headers.get("access-control-expose-headers")).toBe("sb-error-code"); - expect(await authResponse.json()).toEqual({ - code: "UNAUTHORIZED_NO_AUTH_HEADER", - message: "Missing authorization header", - msg: "Missing authorization header", - }); - } finally { - spawnSync("docker", ["rm", "-f", kongContainer, runtimeContainer], { - stdio: "ignore", - }); - spawnSync("docker", ["network", "rm", network], { stdio: "ignore" }); - await rm(dir, { recursive: true, force: true }); - } - }, + yield* writeFileString(join(dir, "index.ts"), yield* bundleServeMainTemplate()); + yield* makeDirectory(join(dir, "functions", "custom"), true); + yield* writeFileString(join(dir, "functions", "custom", "index.ts"), CUSTOM_FUNCTION); + yield* writeFileString(join(dir, "import_map.json"), '{"imports":{}}\n'); + yield* writeKongConfig(dir, runtimeContainer); + + const createNetwork = yield* runCommand("docker", ["network", "create", network]); + expect(createNetwork.status, createNetwork.stderr).toBe(0); + + const runRuntime = yield* runCommand("docker", [ + "run", + "-d", + "--name", + runtimeContainer, + "--network", + network, + "-e", + "SUPABASE_INTERNAL_HOST_PORT=8081", + "-e", + "SUPABASE_INTERNAL_JWT_SECRET=auth-e2e", + "-e", + `SUPABASE_URL=http://${kongContainer}:8000`, + "-e", + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${KONG_FUNCTIONS_CONFIG}`, + "-e", + "SUPABASE_INTERNAL_DEBUG=true", + "-e", + "SHARED=shared", + "-e", + "GLOBAL_ONLY=global", + "-e", + "SUPABASE_INTERNAL_WALLCLOCK_LIMIT_SEC=400", + "-e", + 'SUPABASE_JWKS={"keys":[]}', + "-v", + `${dir}:/app:ro`, + "--entrypoint", + "edge-runtime", + runtimeImage, + "start", + "--main-service=/app", + "--port=8081", + ]); + expect(runRuntime.status, runRuntime.stderr).toBe(0); + + const runKong = yield* runCommand("docker", [ + "run", + "-d", + "--name", + kongContainer, + "--network", + network, + "-p", + "127.0.0.1::8000", + "-e", + "KONG_DATABASE=off", + "-e", + "KONG_DECLARATIVE_CONFIG=/home/kong/kong.yml", + "-e", + "KONG_PLUGINS=request-transformer,cors", + "-e", + "KONG_NGINX_WORKER_PROCESSES=1", + "-v", + `${join(dir, "kong.yml")}:/home/kong/kong.yml:ro`, + kongImage, + "kong", + "docker-start", + ]); + expect(runKong.status, runKong.stderr).toBe(0); + + const portResult = yield* runCommand("docker", ["port", kongContainer, "8000/tcp"]); + expect(portResult.status, portResult.stderr).toBe(0); + const port = Number(portResult.stdout.trim().split(":").at(-1)); + expect(port).toBeGreaterThan(0); + const functionsUrl = `http://127.0.0.1:${port}/functions/v1`; + const authUrl = `${functionsUrl}/test`; + + yield* waitForStatus(authUrl, 401).pipe( + Effect.catch(() => + Effect.gen(function* () { + const [kongLogs, runtimeLogs] = yield* Effect.all([ + containerLogs(kongContainer), + containerLogs(runtimeContainer), + ]); + return yield* Effect.fail(`${kongLogs}\n${runtimeLogs}`); + }), + ), + ); + + const customResponse = yield* httpGet(`${functionsUrl}/custom`, { + origin: "http://localhost:3000", + }); + expect(customResponse.status).toBe(200); + expect(customResponse.headers["x-custom-id"]).toBe("abc123"); + expect(customResponse.headers["x-shared"]).toBe("function"); + expect(customResponse.headers["x-function-only"]).toBe("function"); + expect(customResponse.headers["x-global-only"]).toBe("global"); + expect(customResponse.headers["access-control-expose-headers"]?.toLowerCase()).toBe( + "x-custom-id", + ); + const runtimeLogs = yield* containerLogs(runtimeContainer); + expect(runtimeLogs).toContain("Functions config:"); + expect(runtimeLogs).toContain('"custom"'); + expect(runtimeLogs).not.toContain('"env"'); + expect(runtimeLogs).not.toContain("must-not-appear-in-debug-logs"); + + const authResponse = yield* httpGet(authUrl, { + origin: "http://localhost:3000", + }); + expect(authResponse.status).toBe(401); + expect(authResponse.headers["sb-error-code"]).toBe("UNAUTHORIZED_NO_AUTH_HEADER"); + expect(authResponse.headers["access-control-expose-headers"]).toBe("sb-error-code"); + expect(yield* authResponse.json).toEqual({ + code: "UNAUTHORIZED_NO_AUTH_HEADER", + message: "Missing authorization header", + msg: "Missing authorization header", + }); + } finally { + yield* runCommand("docker", ["rm", "-f", kongContainer, runtimeContainer], { + stdio: "ignore", + }); + yield* runCommand("docker", ["network", "rm", network], { stdio: "ignore" }); + yield* remove(dir); + } + }), + ), ); }); diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 2cf89e2e71..3277d00d8e 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -1,11 +1,42 @@ -// @ts-nocheck +// oxlint-disable effecttsgo/async-function, effecttsgo/global-console -- This file is emitted as raw Deno source and must remain dependency-free at the runtime boundary. declare const Deno: any; -declare const EdgeRuntime: any; + +interface EdgeRuntimeWorker { + fetch(request: Request): Promise<Response>; +} + +interface EdgeRuntimeApi { + applySupabaseTag(request: Request, clonedRequest: Request): void; + getRuntimeMetrics(): Promise<unknown>; + userWorkers: { + create(options: Readonly<Record<string, unknown>>): Promise<EdgeRuntimeWorker>; + }; +} + +declare const EdgeRuntime: EdgeRuntimeApi; import { dirname, join, STATUS_CODE, STATUS_TEXT, toFileUrl } from "./serve-main-deps.ts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import * as jose from "jose"; +class ServeRuntimeError extends Data.TaggedError("ServeRuntimeError")<{ + readonly cause: unknown; +}> {} + +const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJsonText = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function log(...values: ReadonlyArray<unknown>) { + console.log(...values); +} + +function logError(...values: ReadonlyArray<unknown>) { + console.error(...values); +} + const SB_SPECIFIC_ERROR_CODE = { BootError: STATUS_CODE.ServiceUnavailable /** Service Unavailable (RFC 7231, 6.6.4) */, InvalidWorkerResponse: @@ -61,20 +92,57 @@ interface AuthFailure { interface FunctionConfig { entrypointPath: string; - importMapPath: string; - staticFiles: string[]; + importMapPath?: string; + staticFiles?: string[]; verifyJWT: boolean; env?: Record<string, string>; } -function getResponse(payload: any, status: number, customHeaders = {}) { +function isStringRecord(value: unknown): value is Record<string, string> { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === "string") + ); +} + +function isFunctionConfig(value: unknown): value is FunctionConfig { + if (typeof value !== "object" || value === null) { + return false; + } + const entrypointPath = Reflect.get(value, "entrypointPath"); + const importMapPath = Reflect.get(value, "importMapPath"); + const staticFiles = Reflect.get(value, "staticFiles"); + const verifyJWT = Reflect.get(value, "verifyJWT"); + const env = Reflect.get(value, "env"); + return ( + typeof entrypointPath === "string" && + (importMapPath === undefined || typeof importMapPath === "string") && + (staticFiles === undefined || + (Array.isArray(staticFiles) && staticFiles.every((path) => typeof path === "string"))) && + typeof verifyJWT === "boolean" && + (env === undefined || isStringRecord(env)) + ); +} + +function isFunctionsConfig(value: unknown): value is Record<string, FunctionConfig> { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every(isFunctionConfig) + ); +} + +function getResponse(payload: unknown, status: number, customHeaders: Record<string, string> = {}) { const headers = { ...customHeaders }; let body: string | null = null; if (payload) { if (typeof payload === "object") { headers["Content-Type"] = "application/json"; - body = JSON.stringify(payload); + body = JSON.stringify(payload) ?? null; } else if (typeof payload === "string") { headers["Content-Type"] = "text/plain"; body = payload; @@ -104,19 +172,22 @@ function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { const functionsConfig: Record<string, FunctionConfig> = (() => { try { - const functionsConfig = JSON.parse(FUNCTIONS_CONFIG_STRING); + const parsedConfig: unknown = JSON.parse(FUNCTIONS_CONFIG_STRING); + if (!isFunctionsConfig(parsedConfig)) { + throw new Error("functions config has an invalid shape"); + } if (DEBUG) { const debugConfig = Object.fromEntries( - Object.entries(functionsConfig).map(([name, config]) => [ + Object.entries(parsedConfig).map(([name, config]) => [ name, Object.fromEntries(Object.entries(config).filter(([key]) => key !== "env")), ]), ); - console.log("Functions config:", JSON.stringify(debugConfig, null, 2)); + log("Functions config:", JSON.stringify(debugConfig, null, 2)); } - return functionsConfig; + return parsedConfig; } catch (cause) { throw new Error("Failed to parse functions config", { cause }); } @@ -164,63 +235,83 @@ function getAuthToken(req: Request): string | AuthFailure { return token; } -async function isValidLegacyJWT(jwtSecret: string, jwt: string): Promise<AuthFailure | null> { +function isValidLegacyJWT(jwtSecret: string, jwt: string): Effect.Effect<AuthFailure | null> { const encoder = new TextEncoder(); const secretKey = encoder.encode(jwtSecret); - try { - await jose.jwtVerify(jwt, secretKey); - } catch (e) { - console.error("Symmetric Legacy JWT verification error", e); - return { code: RequestErrors.InvalidLegacyJWT }; - } - return null; + return Effect.tryPromise({ + try: () => jose.jwtVerify(jwt, secretKey), + catch: (error) => new ServeRuntimeError({ cause: error }), + }).pipe( + Effect.as<AuthFailure | null>(null), + Effect.catch((error) => + Effect.sync(() => { + const details = describeRuntimeError(error); + logError("Symmetric Legacy JWT verification error", details.message, details.trace ?? ""); + return { code: RequestErrors.InvalidLegacyJWT }; + }), + ), + ); +} + +function isJsonWebKeySet(value: unknown): value is jose.JSONWebKeySet { + const keys = typeof value === "object" && value !== null ? Reflect.get(value, "keys") : undefined; + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Array.isArray(keys) && + keys.every((key) => typeof key === "object" && key !== null && !Array.isArray(key)) + ); } // Lazy-loading JWKs -let jwks = (() => { +let jwks: jose.JWTVerifyGetKey | null = (() => { try { // using injected JWKS from cli - return jose.createLocalJWKSet(JSON.parse(Deno.env.get("SUPABASE_JWKS"))); + const parsedJwks: unknown = decodeJsonText(Deno.env.get("SUPABASE_JWKS")); + return isJsonWebKeySet(parsedJwks) ? jose.createLocalJWKSet(parsedJwks) : null; } catch { return null; } })(); -async function isValidJWT(jwksUrl: URL, jwt: string): Promise<AuthFailure | null> { - try { - if (!jwks) { - // Loading from remote-url on fly - jwks = jose.createRemoteJWKSet(new URL(jwksUrl)); - } - await jose.jwtVerify(jwt, jwks); - } catch (e) { - console.error("Asymmetric JWT verification error", e); - return { code: RequestErrors.InvalidAsymmetricJWT }; - } - return null; +function isValidJWT(jwksUrl: URL, jwt: string): Effect.Effect<AuthFailure | null> { + return Effect.tryPromise({ + try: () => { + if (!jwks) { + // Loading from remote-url on fly + jwks = jose.createRemoteJWKSet(new URL(jwksUrl)); + } + return jose.jwtVerify(jwt, jwks); + }, + catch: (error) => new ServeRuntimeError({ cause: error }), + }).pipe( + Effect.as<AuthFailure | null>(null), + Effect.catch((error) => + Effect.sync(() => { + const details = describeRuntimeError(error); + logError("Asymmetric JWT verification error", details.message, details.trace ?? ""); + return { code: RequestErrors.InvalidAsymmetricJWT }; + }), + ), + ); } /** * Applies hybrid JWT verification, using JWK as primary and Legacy Secret as fallback. * Use only during 'New JWT Keys' migration period, while `JWT_SECRET` is still available. */ -export async function verifyHybridJWT( +export const verifyHybridJWT = Effect.fn("functions.verifyHybridJWT")(function* ( jwtSecret: string, jwksUrl: URL, jwt: string, -): Promise<AuthFailure | null> { - let jwtAlgorithm: string | undefined; - try { - jwtAlgorithm = jose.decodeProtectedHeader(jwt).alg; - } catch (e) { - console.error("JWT format error", e); - return { - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }; - } - - if (!jwtAlgorithm) { +) { + const jwtAlgorithm = yield* Effect.try({ + try: () => jose.decodeProtectedHeader(jwt).alg, + catch: (cause) => new ServeRuntimeError({ cause }), + }).pipe(Effect.orElseSucceed(() => undefined)); + if (jwtAlgorithm === undefined) { + yield* Effect.sync(() => logError("JWT format error")); return { code: RequestErrors.InvalidTokenFormat, message: "Invalid JWT format", @@ -228,45 +319,61 @@ export async function verifyHybridJWT( } if (jwtAlgorithm === "HS256") { - console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`); - - return await isValidLegacyJWT(jwtSecret, jwt); + yield* Effect.sync(() => + log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`), + ); + return yield* isValidLegacyJWT(jwtSecret, jwt); } if (jwtAlgorithm === "ES256" || jwtAlgorithm === "RS256") { - return await isValidJWT(jwksUrl, jwt); + return yield* isValidJWT(jwksUrl, jwt); } return { code: RequestErrors.UnsupportedTokenAlgorithm, message: `Unsupported JWT algorithm ${jwtAlgorithm}`, }; -} +}); // Ref: https://docs.deno.com/examples/checking_file_existence/ -async function shouldUsePackageJsonDiscovery({ +function shouldUsePackageJsonDiscovery({ entrypointPath, importMapPath, -}: FunctionConfig): Promise<boolean> { +}: FunctionConfig): Effect.Effect<boolean> { if (importMapPath) { - return false; + return Effect.succeed(false); } const packageJsonPath = join(dirname(entrypointPath), "package.json"); - try { - await Deno.lstat(packageJsonPath); - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - } - return true; + return Effect.tryPromise({ + try: () => Deno.lstat(packageJsonPath), + catch: (error) => new ServeRuntimeError({ cause: error }), + }).pipe( + Effect.as(true), + Effect.catch((error) => { + const cause = error instanceof ServeRuntimeError ? error.cause : error; + return cause instanceof Deno.errors.NotFound ? Effect.succeed(false) : Effect.die(error); + }), + ); +} + +function describeRuntimeError(error: unknown) { + const cause = error instanceof ServeRuntimeError ? error.cause : error; + const message = cause instanceof Error ? cause.message : String(cause); + const trace = + cause instanceof Error ? cause.stack : error instanceof Error ? error.stack : undefined; + return { cause, message, trace }; } export function prepareUserRequest(req: Request): Request { const clonedURL = new URL(req.url); const forwardedHost = req.headers.get("x-forwarded-host"); clonedURL.hostname = forwardedHost ?? clonedURL.hostname; - const clonedReq = new Request(clonedURL, req.clone()); + const source = req.clone(); + const constructed: unknown = Reflect.construct(Request, [clonedURL.href, source]); + if (!(constructed instanceof Request)) { + throw new TypeError("failed to clone request"); + } + const clonedReq = constructed; // remove custom api headers clonedReq.headers.delete("sb-api-key"); @@ -276,150 +383,172 @@ export function prepareUserRequest(req: Request): Request { } Deno.serve({ - handler: async (req: Request) => { - const url = new URL(req.url); - const { pathname } = url; + handler: (req: Request) => + Effect.runPromise( + Effect.gen(function* () { + const url = new URL(req.url); + const { pathname } = url; + + // handle health checks + if (pathname === "/_internal/health") { + return getResponse({ message: "ok" }, STATUS_CODE.OK); + } - // handle health checks - if (pathname === "/_internal/health") { - return getResponse({ message: "ok" }, STATUS_CODE.OK); - } + // handle metrics + if (pathname === "/_internal/metric") { + const metric = yield* Effect.tryPromise({ + try: () => EdgeRuntime.getRuntimeMetrics(), + catch: (error) => new ServeRuntimeError({ cause: error }), + }); + return Response.json(metric); + } - // handle metrics - if (pathname === "/_internal/metric") { - const metric = await EdgeRuntime.getRuntimeMetrics(); - return Response.json(metric); - } + const pathParts = pathname.split("/"); + const functionName = pathParts[1]; + const functionConfig = + functionName === undefined ? undefined : functionsConfig[functionName]; - const pathParts = pathname.split("/"); - const functionName = pathParts[1]; + if (!functionName || functionConfig === undefined) { + return getResponse("Function not found", STATUS_CODE.NotFound); + } - if (!functionName || !(functionName in functionsConfig)) { - return getResponse("Function not found", STATUS_CODE.NotFound); - } + if (req.method !== "OPTIONS" && functionConfig.verifyJWT) { + const token = yield* Effect.try({ + try: () => getAuthToken(req), + catch: () => ({ + code: RequestErrors.InvalidTokenFormat, + message: "Invalid JWT format", + }), + }); + if (typeof token !== "string") { + return getAuthErrorResponse(token); + } + const authFailure = yield* verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); + if (authFailure) { + return getAuthErrorResponse(authFailure); + } + } - if (req.method !== "OPTIONS" && functionsConfig[functionName].verifyJWT) { - try { - const token = getAuthToken(req); - if (typeof token !== "string") { - return getAuthErrorResponse(token); + const servicePath = dirname(functionConfig.entrypointPath); + yield* Effect.sync(() => logError(`serving the request with ${servicePath}`)); + + // Ref: https://supabase.com/docs/guides/functions/limits + const memoryLimitMb = 256; + const workerTimeoutMs = isFinite(WALLCLOCK_LIMIT_SEC) + ? WALLCLOCK_LIMIT_SEC * 1000 + : 400 * 1000; + const noModuleCache = false; + const envVarsObj = { + ...Deno.env.toObject(), + ...Object.fromEntries( + Object.entries(functionConfig.env ?? {}).filter( + ([name, _]) => !name.startsWith("SUPABASE_"), + ), + ), + }; + if (SUPABASE_PUBLISHABLE_KEY) { + envVarsObj["SUPABASE_PUBLISHABLE_KEYS"] = encodeJsonText({ + default: SUPABASE_PUBLISHABLE_KEY, + }); } - const authFailure = await verifyHybridJWT(JWT_SECRET, JWKS_ENDPOINT, token); - if (authFailure) { - return getAuthErrorResponse(authFailure); + if (SUPABASE_SECRET_KEY) { + envVarsObj["SUPABASE_SECRET_KEYS"] = encodeJsonText({ + default: SUPABASE_SECRET_KEY, + }); } - } catch (e) { - console.error(e); - return getAuthErrorResponse({ - code: RequestErrors.InvalidTokenFormat, - message: "Invalid JWT format", - }); - } - } - - const servicePath = dirname(functionsConfig[functionName].entrypointPath); - console.error(`serving the request with ${servicePath}`); - - // Ref: https://supabase.com/docs/guides/functions/limits - const memoryLimitMb = 256; - const workerTimeoutMs = isFinite(WALLCLOCK_LIMIT_SEC) ? WALLCLOCK_LIMIT_SEC * 1000 : 400 * 1000; - const noModuleCache = false; - const envVarsObj = { - ...Deno.env.toObject(), - ...Object.fromEntries( - Object.entries(functionsConfig[functionName].env ?? {}).filter( - ([name, _]) => !name.startsWith("SUPABASE_"), - ), - ), - }; - if (SUPABASE_PUBLISHABLE_KEY) { - envVarsObj["SUPABASE_PUBLISHABLE_KEYS"] = JSON.stringify({ - default: SUPABASE_PUBLISHABLE_KEY, - }); - } - if (SUPABASE_SECRET_KEY) { - envVarsObj["SUPABASE_SECRET_KEYS"] = JSON.stringify({ - default: SUPABASE_SECRET_KEY, - }); - } - - const envVars = Object.entries(envVarsObj).filter( - ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), - ); - - const forceCreate = false; - const customModuleRoot = ""; // empty string to allow any local path - const cpuTimeSoftLimitMs = 1000; - const cpuTimeHardLimitMs = 2000; - - // NOTE(Nyannyacha): Decorator type has been set to tc39 by Lakshan's request, - // but in my opinion, we should probably expose this to customers at some - // point, as their migration process will not be easy. - // This need to be kept for Deno 1 compatibility. - const decoratorType = "tc39"; - - const absEntrypoint = join(Deno.cwd(), functionsConfig[functionName].entrypointPath); - const maybeEntrypoint = toFileUrl(absEntrypoint).href; - const usePackageJson = await shouldUsePackageJsonDiscovery(functionsConfig[functionName]); - const staticPatterns = functionsConfig[functionName].staticFiles; - - try { - const worker = await EdgeRuntime.userWorkers.create({ - servicePath, - memoryLimitMb, - workerTimeoutMs, - noModuleCache, - noNpm: !usePackageJson, - importMapPath: functionsConfig[functionName].importMapPath, - envVars, - forceCreate, - customModuleRoot, - cpuTimeSoftLimitMs, - cpuTimeHardLimitMs, - decoratorType, - maybeEntrypoint, - context: { - useReadSyncFileAPI: true, - }, - staticPatterns, - }); - - const userReq = prepareUserRequest(req); - return await worker.fetch(userReq); - } catch (e) { - console.error(e); - - for (const [denoError, sbCode] of DENO_SB_ERROR_MAP.entries()) { - if (denoError !== void 0 && e instanceof denoError) { - return getResponse( - { - code: SB_SPECIFIC_ERROR_TEXT[sbCode], - message: SB_SPECIFIC_ERROR_REASON[sbCode], - }, - sbCode, - ); - } - } + const envVars = Object.entries(envVarsObj).filter( + ([name, _]) => !EXCLUDED_ENVS.includes(name) && !name.startsWith("SUPABASE_INTERNAL_"), + ); - return getResponse( - { - code: STATUS_TEXT[STATUS_CODE.InternalServerError], - message: "Request failed due to an internal server error", - trace: JSON.stringify(e.stack), - }, - STATUS_CODE.InternalServerError, - ); - } - }, + const forceCreate = false; + const customModuleRoot = ""; // empty string to allow any local path + const cpuTimeSoftLimitMs = 1000; + const cpuTimeHardLimitMs = 2000; + + // NOTE(Nyannyacha): Decorator type has been set to tc39 by Lakshan's request, + // but in my opinion, we should probably expose this to customers at some + // point, as their migration process will not be easy. + // This need to be kept for Deno 1 compatibility. + const decoratorType = "tc39"; + + const absEntrypoint = join(Deno.cwd(), functionConfig.entrypointPath); + const maybeEntrypoint = toFileUrl(absEntrypoint).href; + const usePackageJson = yield* shouldUsePackageJsonDiscovery(functionConfig); + + const staticPatterns = functionConfig.staticFiles; + + const workerResult = yield* Effect.tryPromise({ + try: () => + EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + noNpm: !usePackageJson, + importMapPath: functionConfig.importMapPath, + envVars, + forceCreate, + customModuleRoot, + cpuTimeSoftLimitMs, + cpuTimeHardLimitMs, + decoratorType, + maybeEntrypoint, + context: { + useReadSyncFileAPI: true, + }, + staticPatterns, + }), + catch: (error) => new ServeRuntimeError({ cause: error }), + }).pipe( + Effect.flatMap((worker) => + Effect.tryPromise({ + try: () => worker.fetch(prepareUserRequest(req)), + catch: (error) => new ServeRuntimeError({ cause: error }), + }), + ), + Effect.catch((error) => + Effect.sync(() => { + const details = describeRuntimeError(error); + logError(details.message, details.trace ?? ""); + + for (const [denoError, sbCode] of DENO_SB_ERROR_MAP.entries()) { + if (denoError !== void 0 && details.cause instanceof denoError) { + return getResponse( + { + code: SB_SPECIFIC_ERROR_TEXT[sbCode], + message: SB_SPECIFIC_ERROR_REASON[sbCode], + }, + sbCode, + ); + } + } + + return getResponse( + { + code: STATUS_TEXT[STATUS_CODE.InternalServerError], + message: "Request failed due to an internal server error", + trace: encodeJsonText(details.trace ?? details.message), + }, + STATUS_CODE.InternalServerError, + ); + }), + ), + ); + return workerResult; + }), + ), onListen: () => { try { const functionsConfigString = Deno.env.get("SUPABASE_INTERNAL_FUNCTIONS_CONFIG"); if (functionsConfigString) { const MAX_FUNCTIONS_URL_EXAMPLES = 5; - const functionsConfig = JSON.parse(functionsConfigString) as Record<string, unknown>; - const functionNames = Object.keys(functionsConfig); + const parsedConfig: unknown = decodeJsonText(functionsConfigString); + if (!isFunctionsConfig(parsedConfig)) { + throw new Error("functions config has an invalid shape"); + } + const functionNames = Object.keys(parsedConfig); const exampleFunctions = functionNames.slice(0, MAX_FUNCTIONS_URL_EXAMPLES); const functionsUrls = exampleFunctions.map( (fname) => ` - http://127.0.0.1:${HOST_PORT}/functions/v1/${fname}`, @@ -432,21 +561,23 @@ Deno.serve({ : "" }` : ""; - console.log( + log( `${GENERIC_FUNCTION_SERVE_MESSAGE}${functionsExamplesMessages}\nUsing ${Deno.version.deno}`, ); } } catch { - console.log(`${GENERIC_FUNCTION_SERVE_MESSAGE}\nUsing ${Deno.version.deno}`); + log(`${GENERIC_FUNCTION_SERVE_MESSAGE}\nUsing ${Deno.version.deno}`); } }, - onError: (e) => { + onError: (e: unknown) => { + const details = describeRuntimeError(e); + logError(details.message, details.trace ?? ""); return getResponse( { code: STATUS_TEXT[STATUS_CODE.InternalServerError], message: "Request failed due to an internal server error", - trace: JSON.stringify(e.stack), + trace: encodeJsonText(details.trace ?? details.message), }, STATUS_CODE.InternalServerError, ); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 479889d723..d1d6dc53bf 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -22,11 +22,23 @@ import { sign as signJwtBytes, type JsonWebKeyInput, } from "node:crypto"; -import { existsSync, watch } from "node:fs"; -import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { styleText } from "node:util"; -import { Cause, Duration, Effect, Layer, Option, Queue, Redacted, Schema, Stream } from "effect"; +import { BunPath } from "@effect/platform-bun"; +import { + Clock, + Config, + Duration, + Effect, + FileSystem, + Layer, + Match, + Option, + Predicate, + Redacted, + Schema, + Stream, +} from "effect"; +import * as EffectPath from "effect/Path"; import { ChildProcessSpawner } from "effect/unstable/process"; import { legacyDescribeContainerCliFailure, @@ -76,6 +88,15 @@ import { } from "./functions-docker.ts"; import { loadFunctionsProjectConfig, type FunctionsGoConfigCompat } from "./functions-config.ts"; import { edgeRuntimeImage, resolveEdgeRuntimeVersionPin } from "./functions.shared.ts"; +import { FunctionsOperationError } from "./functions-api.errors.ts"; +import { + findPlatformError, + legacyFilesystemErrorMessage, +} from "../legacy/legacy-filesystem-error.ts"; + +const { basename, dirname, isAbsolute, join, relative, resolve } = Effect.runSync( + EffectPath.Path.pipe(Effect.provide(BunPath.layer)), +); const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeProjectConfig({}); @@ -84,6 +105,8 @@ const dockerRuntimeInspectorPort = 8083; // Unix timestamp (~2032-11-30) used as the `exp` claim of the local-dev default // JWTs, matching the Go CLI's hardcoded expiry for anon/service_role tokens. const defaultJwtExpiry = 1983812996; +const encodeJsonText = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const defaultSigningKey = { kty: "EC", kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", @@ -111,6 +134,7 @@ const defaultSupabaseEnv = "development"; const serveMainContainerPath = "/root/index.ts"; const shellVariableNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; let cachedLegacyFunctionsServeMainTemplate: string | undefined; + const watchIgnoreGlobs = [ "**/.git/**", "**/node_modules/**", @@ -275,6 +299,8 @@ export interface StartEdgeRuntimeContainerInput { readonly dbUrl: string; /** Already-resolved edge-runtime image reference (registry-mapped, tag/deno-version already applied). */ readonly image: string; + /** Go's merged project environment, used for Bitbucket's named-volume restriction. */ + readonly projectEnvValues?: Readonly<Record<string, string>>; readonly projectRoot: string; readonly supabaseDir: string; readonly flagCwd: string; @@ -306,37 +332,26 @@ type SigningKeyJwk = JsonWebKeyInput["key"] & { declare const SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: string | undefined; -export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => - FileWatcher.of({ - watch: (root) => - Stream.callback<ReadonlyArray<FileWatchEvent>, FileWatcherError>((queue) => - Effect.acquireRelease( - Effect.sync(() => { - const watcher = watch(root, { recursive: true }, (eventType, filename) => { - const pathname = - filename === null || filename === undefined || filename.length === 0 - ? root - : resolve(root, filename.toString()); - // Node's `fs.watch` only distinguishes "rename" (create/delete/ - // rename) from "change" (write); Go prints the real fsnotify op - // (`internal/functions/serve/watcher.go:100`). The closest - // recoverable equivalent is an existence check on "rename" - // events: present → create, gone → delete; "change" → update. - const type: FileWatchEvent["type"] = - eventType === "rename" ? (existsSync(pathname) ? "create" : "delete") : "update"; - Queue.offerUnsafe(queue, [{ path: pathname, type }]); - }); - watcher.on("error", (cause) => { - Queue.failCauseUnsafe(queue, Cause.fail(new FileWatcherError({ path: root, cause }))); - }); - return watcher; - }), - (watcher) => - Effect.sync(() => { - watcher.close(); - }), +export const serveFileWatcherLayer = Layer.effect( + FileWatcher, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return FileWatcher.of({ + watch: (root) => + fs.watch(root, { recursive: true }).pipe( + Stream.map((event) => [ + { + path: event.path, + type: Match.valueTags(event, { + Create: () => "create" as const, + Update: () => "update" as const, + Remove: () => "delete" as const, + }), + } satisfies FileWatchEvent, + ]), + Stream.mapError((cause) => new FileWatcherError({ path: root, cause })), ), - ), + }); }), ); @@ -351,23 +366,30 @@ export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => * shipped binary never bundles at runtime. Running from source (`bun src/supabase.ts`) * bundles on demand. */ -function getLegacyFunctionsServeMainTemplate(): Promise<string> { +function getLegacyFunctionsServeMainTemplate() { if (cachedLegacyFunctionsServeMainTemplate !== undefined) { - return Promise.resolve(cachedLegacyFunctionsServeMainTemplate); + return Effect.succeed(cachedLegacyFunctionsServeMainTemplate); } if (typeof SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE === "string") { cachedLegacyFunctionsServeMainTemplate = SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE; - return Promise.resolve(cachedLegacyFunctionsServeMainTemplate); + return Effect.succeed(cachedLegacyFunctionsServeMainTemplate); } // Running from source: the build-time define is absent, so bundle on demand. The // bundler (and its esbuild dependency) is imported lazily and only here, so it is // never loaded by shipped binaries — which always take the define branch above. - return import("./serve-main-bundler.ts") - .then(({ bundleServeMainTemplate }) => bundleServeMainTemplate()) - .then((bundled) => { - cachedLegacyFunctionsServeMainTemplate = bundled; - return bundled; + return Effect.gen(function* () { + const { bundleServeMainTemplate } = yield* Effect.tryPromise({ + try: () => import("./serve-main-bundler.ts"), + catch: (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), }); + const bundled = yield* bundleServeMainTemplate(); + cachedLegacyFunctionsServeMainTemplate = bundled; + return bundled; + }); } function reveal(value: string | Redacted.Redacted<string> | undefined): string | undefined { @@ -500,7 +522,7 @@ function generateSymmetricJwt(secret: string, role: string) { return `${data}.${signature}`; } -function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string) { +function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string, nowSeconds: number) { const algorithm = signingKey.alg; if (algorithm !== "ES256" && algorithm !== "RS256") { throw new Error(`unsupported algorithm: ${String(algorithm)}`); @@ -514,7 +536,7 @@ function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string) { const payload = { iss: "supabase-demo", role, - exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365 * 10, + exp: nowSeconds + 60 * 60 * 24 * 365 * 10, }; const encodedHeader = encodeBase64Url(JSON.stringify(header)); const encodedPayload = encodeBase64Url(JSON.stringify(payload)); @@ -530,12 +552,48 @@ function generateAsymmetricJwt(signingKey: SigningKeyJwk, role: string) { return `${data}.${signature}`; } -async function readSigningKeys(pathname: string): Promise<ReadonlyArray<SigningKeyJwk>> { - const decoded = JSON.parse(await readFile(pathname, "utf8")); - if (!Array.isArray(decoded)) { - throw new Error("expected a JSON array"); - } - return decoded as ReadonlyArray<SigningKeyJwk>; +function isSigningKeyJwk(value: unknown): value is SigningKeyJwk { + return ( + typeof value === "object" && + value !== null && + "kty" in value && + (value.kty === "EC" || value.kty === "RSA") + ); +} + +function readSigningKeys(pathname: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs.readFileString(pathname).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: `failed to read signing keys: ${legacyFilesystemErrorMessage(cause)}`, + cause, + }), + ), + ); + return yield* Effect.succeed(contents); + }).pipe( + Effect.flatMap((contents) => + Effect.try({ + try: () => decodeJsonText(contents), + catch: (cause) => + new FunctionsOperationError({ + message: `failed to decode signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), + }), + ), + Effect.flatMap((decoded) => { + if (!Array.isArray(decoded) || !decoded.every(isSigningKeyJwk)) { + return Effect.fail( + new FunctionsOperationError({ message: "expected a JSON array of signing keys" }), + ); + } + return Effect.succeed(decoded); + }), + ); } /** @@ -581,38 +639,32 @@ const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( auth.signing_keys_path, ); - const signingKeys = yield* Effect.tryPromise({ - try: async () => (signingKeysPath.length === 0 ? [] : await readSigningKeys(signingKeysPath)), - catch: (cause) => { - if (cause instanceof SyntaxError) { - return new Error(`failed to decode signing keys: ${cause.message}`); - } - return new Error( - `failed to read signing keys: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - }, - }); + const signingKeys = + signingKeysPath.length === 0 + ? yield* Effect.succeed<ReadonlyArray<SigningKeyJwk>>([]) + : yield* readSigningKeys(signingKeysPath); const jwtSecret = auth.jwt_secret === undefined || auth.jwt_secret.length === 0 ? defaultJwtSecret : auth.jwt_secret; + const nowSeconds = Math.floor((yield* Clock.currentTimeMillis) / 1000); if (jwtSecret.length < 16) { - return yield* Effect.fail( - new Error("Invalid config for auth.jwt_secret. Must be at least 16 characters"), - ); + return yield* new FunctionsOperationError({ + message: "Invalid config for auth.jwt_secret. Must be at least 16 characters", + }); } const anonKey = auth.anon_key === undefined || auth.anon_key.length === 0 ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "anon") + ? generateAsymmetricJwt(signingKeys[0]!, "anon", nowSeconds) : generateSymmetricJwt(jwtSecret, "anon") : auth.anon_key; const serviceRoleKey = auth.service_role_key === undefined || auth.service_role_key.length === 0 ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "service_role") + ? generateAsymmetricJwt(signingKeys[0]!, "service_role", nowSeconds) : generateSymmetricJwt(jwtSecret, "service_role") : auth.service_role_key; const shouldUseJwtSecretFallback = signingKeysPath.length === 0; @@ -672,12 +724,19 @@ const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( */ const finalizeAuthArtifacts = Effect.fnUntraced(function* (local: ServeLocalAuthArtifacts) { const keys: unknown[] = []; + const emptyRemoteJwks: ReadonlyArray<unknown> = []; if (local.issuerUrl !== undefined) { const issuerUrl = local.issuerUrl; - const remoteJwks = yield* Effect.tryPromise({ - try: () => resolveRemoteJwks(issuerUrl), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }).pipe(Effect.catch(() => Effect.succeed([] as ReadonlyArray<unknown>))); + const remoteJwks = yield* resolveRemoteJwks(issuerUrl).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: cause.message, + cause, + }), + ), + Effect.orElseSucceed(() => emptyRemoteJwks), + ); keys.push(...remoteJwks); } keys.push(...local.localKeys); @@ -688,7 +747,7 @@ const finalizeAuthArtifacts = Effect.fnUntraced(function* (local: ServeLocalAuth jwtSecret: local.jwtSecret, anonKey: local.anonKey, serviceRoleKey: local.serviceRoleKey, - jwks: JSON.stringify({ keys }), + jwks: encodeJsonText({ keys }), } satisfies ServeAuthArtifacts; }); @@ -699,13 +758,11 @@ const resolveServeConfig = Effect.fnUntraced(function* ( goConfigCompat: FunctionsGoConfigCompat | undefined, ) { const projectEnv = yield* loadServeProjectEnvironment(projectRoot); - const projectRef = Option.match(projectIdOverride, { - onNone: () => undefined, - onSome: (value) => { - const normalized = value.trim(); - return normalized.length > 0 ? normalized : undefined; - }, - }); + const projectRef = Option.filter( + Option.map(projectIdOverride, (value) => value.trim()), + (value) => value.length > 0, + ); + const projectRefValue = Option.getOrUndefined(projectRef); // `loadProjectConfig` interpolates `env()` references against the project // environment. We resolve that environment ourselves (Go-accurate, layering // `.env.<SUPABASE_ENV>`/`.env.local`/`.env` over the ambient env) and pass it @@ -720,7 +777,7 @@ const resolveServeConfig = Effect.fnUntraced(function* ( // different projects. `next` (`goConfigCompat === undefined`) keeps the // package defaults (ancestor search, JSON preferred), unchanged. const loadedConfig = yield* loadProjectConfig(projectRoot, { - ...(projectRef === undefined ? {} : { projectRef }), + ...(projectRefValue === undefined ? {} : { projectRef: projectRefValue }), ...(projectEnv === null ? {} : { projectEnv }), goViperCompat, ...(goConfigCompat === undefined ? {} : { search: false, tomlOnly: true }), @@ -769,49 +826,22 @@ const resolveServeConfig = Effect.fnUntraced(function* ( goViperCompat, }), ) ?? ""); - const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); - const fallbackProjectId = basename(resolve(projectRoot)); - - // Go: `flags.LoadConfig` -> `Config.Validate` (`pkg/config/config.go:878,989-1192`) - // — `restartEdgeRuntime` runs this FIRST, before `AssertSupabaseDbIsRunning` - // (see this function's own caller for that ordering) — so an invalid - // config must fail here too, before any Docker check. Legacy shell only; - // `next` keeps its own package-default config resolution above unchanged. - // A second, independent config/dotenv load (rather than reusing this - // function's own `loadedConfig`/`projectEnv` above) — that pipeline's - // `env(...)`-interpolation purpose is unrelated to Go's `SUPABASE_*` - // `AutomaticEnv` override system this one provides, and the two shouldn't - // be entangled for a shipped, long-running command's config path. - // `search`/`tomlOnly` are aligned with this file's own `loadedConfig` call - // above (see its comment) so the two loads can never disagree about which - // file is "the" project config. `projectEnvValues` (for registry/network-id - // env lookups, this file's own caller) and the env-overridden - // `deno_version` are consumed from it; `auth`/`apiPort`/functions above - // keep their existing derivation. `projectId` also keeps its existing - // derivation — a known gap, narrow to trigger but NOT cosmetic when hit: - // unlike `deploy`/`download` (which use `context.projectId` outright), - // `rawProjectId` below only ever sees `SUPABASE_PROJECT_ID` from the - // *ambient* shell (`projectIdOverride`, from `LegacyCliConfig`), not from - // project dotenv. A project that sets it only in `supabase/.env` therefore - // gets a different `supabase_edge_runtime_<id>`/`supabase_network_<id>` - // here than `deploy`/`download`/`start` resolve for the SAME project — so - // `serve` creates a second network and a container `reloadKong(projectId)`'s - // Kong (named off the other id) can't route to: a silently non-functional - // `serve`, where Go reads one `Config.ProjectId` for everything. Folding - // `goContext.projectEnvValues` in here would also require reconciling this - // function's `projectIdOverride`-wins-unconditionally precedence with - // `legacyResolveLocalProjectId`'s config-file-wins-over-`projectRef` - // precedence (they're not the same order) — left open rather than risking - // that regression under time pressure (review round on CLI-1963). const goContext = goConfigCompat === undefined ? undefined : yield* loadFunctionsProjectConfig({ projectRoot, - projectRef, + projectRef: projectRefValue, goConfigCompat, }); + const rawProjectId = Option.match(projectRef, { + onNone: () => (goContext?.projectId ?? configProjectId).trim(), + onSome: (value) => value, + }); + const fallbackProjectId = basename(resolve(projectRoot)); + // Resolve and normalize the project ID after loading config and environment + // values so the same ID drives runtime, network, and container naming. return { projectId: normalizeProjectId(rawProjectId.length > 0 ? rawProjectId : fallbackProjectId), apiPort, @@ -864,23 +894,19 @@ export function buildFunctionsServeInspectArgs( } const readDotEnvFile = Effect.fnUntraced(function* (pathname: string, optional: boolean) { - const contents = yield* Effect.tryPromise({ - try: () => - readFile(pathname, "utf8").then( - (value) => value, - (error) => { - if (optional && error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, - ), - catch: (cause) => - new Error( - `failed to load environment file: ${pathname}${cause instanceof Error ? ` (${cause.message})` : ""}`, - { cause }, - ), - }); + const fs = yield* FileSystem.FileSystem; + const contents = yield* fs.readFileString(pathname).pipe( + Effect.catch((cause) => + optional && Predicate.isTagged(cause.reason, "NotFound") ? Effect.void : Effect.fail(cause), + ), + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: `failed to load environment file: ${pathname} (${legacyFilesystemErrorMessage(cause)})`, + cause, + }), + ), + ); if (contents === undefined) { return {}; } @@ -956,77 +982,84 @@ function splitEnvEntry(entry: string) { : ([entry.slice(0, separatorIndex), entry.slice(separatorIndex + 1)] as const); } -async function writeDockerEnvFile(env: Readonly<Record<string, string>>, dir: string) { - const entries = Object.entries(env); - if (entries.length === 0) { - return undefined; - } +function writeDockerEnvFile(env: Readonly<Record<string, string>>, dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const entries = Object.entries(env); + if (entries.length === 0) { + return undefined; + } - // Self-healing: `dir` is a deterministic, reused path (not a fresh mkdtemp - // each call), so a stale directory from an earlier invocation in the same - // process (e.g. `functions serve`'s watch-mode restart loop) is removed - // first — otherwise leftover files from a shrinking env set would survive - // alongside the fresh write. - await rm(dir, { recursive: true, force: true }); - await mkdir(dir, { recursive: true, mode: 0o700 }); - const path = join(dir, "docker.env"); - // The file holds the JWT secret, anon/service-role keys, and JWKS, so keep it - // owner-only rather than relying on the process umask. - await writeFile( - path, - entries - .map(([name, value]) => `${name}=${value.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}`) - .join("\n"), - { mode: 0o600 }, - ); + // Self-healing: `dir` is a deterministic, reused path (not a fresh mkdtemp + // each call), so a stale directory from an earlier invocation in the same + // process (e.g. `functions serve`'s watch-mode restart loop) is removed + // first — otherwise leftover files from a shrinking env set would survive + // alongside the fresh write. + yield* fs.remove(dir, { recursive: true, force: true }); + yield* fs.makeDirectory(dir, { recursive: true, mode: 0o700 }); + const path = join(dir, "docker.env"); + // The file holds the JWT secret, anon/service-role keys, and JWKS, so keep it + // owner-only rather than relying on the process umask. + yield* fs.writeFileString( + path, + entries + .map(([name, value]) => `${name}=${value.replaceAll("\r", "\\r").replaceAll("\n", "\\n")}`) + .join("\n"), + { mode: 0o600 }, + ); - return { path }; + return { path }; + }); } -async function writeDockerMultilineEnvScript( +function writeDockerMultilineEnvScript( env: ReadonlyArray<readonly [string, string]>, containerDir: string, dir: string, ) { - // Self-healing — see the matching comment in `writeDockerEnvFile` above. - // Runs unconditionally, before the `env.length === 0` check, so a stale - // directory left by an earlier invocation that DID need multiline secrets - // is still reclaimed even when the current invocation doesn't. - await rm(dir, { recursive: true, force: true }); - - if (env.length === 0) { - return undefined; - } + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + // Self-healing — see the matching comment in `writeDockerEnvFile` above. + // Runs unconditionally, before the `env.length === 0` check, so a stale + // directory left by an earlier invocation that DID need multiline secrets + // is still reclaimed even when the current invocation doesn't. + yield* fs.remove(dir, { recursive: true, force: true }); + + if (env.length === 0) { + return undefined; + } - await mkdir(dir, { recursive: true, mode: 0o700 }); - const scriptName = "multiline-env.sh"; - const path = join(dir, scriptName); - const envDir = join(containerDir, "values"); - const hostEnvDir = join(dir, "values"); - // Names are validated by `validateDockerMultilineEnvNames` before this runs. - const script = env - .map(([name], index) => { - const valueFile = `env-${index}`; - const valuePath = join(envDir, valueFile).replaceAll("\\", "/"); - return `${name}="$(cat ${valuePath}; printf x)" + yield* fs.makeDirectory(dir, { recursive: true, mode: 0o700 }); + const scriptName = "multiline-env.sh"; + const path = join(dir, scriptName); + const envDir = join(containerDir, "values"); + const hostEnvDir = join(dir, "values"); + // Names are validated by `validateDockerMultilineEnvNames` before this runs. + const script = env + .map(([name], index) => { + const valueFile = `env-${index}`; + const valuePath = join(envDir, valueFile).replaceAll("\\", "/"); + return `${name}="$(cat ${valuePath}; printf x)" export ${name}="\${${name}%x}"`; - }) - .join("\n"); - await mkdir(hostEnvDir, { recursive: true, mode: 0o700 }); - // The value files hold secret env values, so keep them owner-only. - await Promise.all( - env.map(([_, value], index) => - writeFile(join(hostEnvDir, `env-${index}`), value, { mode: 0o600 }), - ), - ); - await writeFile(path, script, { mode: 0o600 }); - - return { - // `Z`: private SELinux relabel of this CLI-staged dir (supabase/cli#5989); - // single-consumer bind, no-op without SELinux. - bind: `${dir}:${containerDir}:ro,Z`, - scriptPath: join(containerDir, scriptName).replaceAll("\\", "/"), - }; + }) + .join("\n"); + yield* fs.makeDirectory(hostEnvDir, { recursive: true, mode: 0o700 }); + // The value files hold secret env values, so keep them owner-only. + yield* Effect.all( + env.map(([_, value], index) => + fs.writeFileString(join(hostEnvDir, `env-${index}`), value, { mode: 0o600 }), + ), + { concurrency: "unbounded" }, + ); + yield* fs.writeFileString(path, script, { mode: 0o600 }); + + return { + // `Z`: private SELinux relabel of this CLI-staged dir (supabase/cli#5989); + // single-consumer bind, no-op without SELinux. + bind: `${dir}:${containerDir}:ro,Z`, + scriptPath: join(containerDir, scriptName).replaceAll("\\", "/"), + }; + }); } function partitionDockerEnvEntries(env: Readonly<Record<string, string>>) { @@ -1053,12 +1086,21 @@ function validateDockerMultilineEnvNames(env: ReadonlyArray<readonly [string, st } function loadDefaultEnvFilenames(env: string) { - return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; + const resolvedEnv = env || defaultSupabaseEnv; + return [ + `.env.${resolvedEnv}.local`, + ...(resolvedEnv === "test" ? [] : [".env.local"]), + `.env.${resolvedEnv}`, + ".env", + ]; } function sanitizeDotEnvParseError(path: string, cause: unknown) { if (!(cause instanceof Error)) { - return new Error(`failed to parse environment file: ${path}`); + return new FunctionsOperationError({ + message: `failed to parse environment file: ${path}`, + cause, + }); } const message = cause.message; if (message.startsWith('unexpected character "')) { @@ -1069,22 +1111,30 @@ function sanitizeDotEnvParseError(path: string, cause: unknown) { const charEnd = message.indexOf('"', charStart); if (charEnd !== -1) { const char = message.slice(charStart, charEnd); - return new Error( - `failed to parse environment file: ${path} (unexpected character '${char}' in variable name)`, - ); + return new FunctionsOperationError({ + message: `failed to parse environment file: ${path} (unexpected character '${char}' in variable name)`, + cause, + }); } } - return new Error( - `failed to parse environment file: ${path} (unexpected character in variable name)`, - ); + return new FunctionsOperationError({ + message: `failed to parse environment file: ${path} (unexpected character in variable name)`, + cause, + }); } if (message.startsWith("unterminated quoted value")) { - return new Error(`failed to parse environment file: ${path} (unterminated quoted value)`); + return new FunctionsOperationError({ + message: `failed to parse environment file: ${path} (unterminated quoted value)`, + cause, + }); } if (message.includes("\n")) { - return new Error(`failed to parse environment file: ${path} (syntax error)`); + return new FunctionsOperationError({ + message: `failed to parse environment file: ${path} (syntax error)`, + cause, + }); } - return new Error(`failed to load ${path}: ${message}`); + return new FunctionsOperationError({ message: `failed to load ${path}: ${message}`, cause }); } function ambientProjectEnv() { @@ -1096,6 +1146,7 @@ function ambientProjectEnv() { } const loadServeProjectEnvironment = Effect.fnUntraced(function* (projectRoot: string) { + const fs = yield* FileSystem.FileSystem; const paths = yield* findProjectPaths(projectRoot); if (paths === null) { return null; @@ -1106,24 +1157,26 @@ const loadServeProjectEnvironment = Effect.fnUntraced(function* (projectRoot: st Object.keys(values).map((key) => [key, "ambient"]), ); const loadedPaths: string[] = []; - const env = process.env["SUPABASE_ENV"] || defaultSupabaseEnv; + const env = Option.getOrElse( + yield* Config.option(Config.string("SUPABASE_ENV")), + () => defaultSupabaseEnv, + ); for (const dir of [paths.supabaseDir, paths.projectRoot]) { for (const filename of loadDefaultEnvFilenames(env)) { const envPath = join(dir, filename); - const contents = yield* Effect.tryPromise({ - try: () => - readFile(envPath, "utf8").then( - (value) => value, - (error) => { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return undefined; - } - throw error; - }, - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + const contents = yield* fs.readFileString(envPath).pipe( + Effect.catch((cause) => + Predicate.isTagged(cause.reason, "NotFound") ? Effect.void : Effect.fail(cause), + ), + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), + ); if (contents === undefined) { continue; } @@ -1161,18 +1214,22 @@ function hasBindUnder(binds: Iterable<string>, containerPath: string): boolean { return false; } -async function buildWatchSpecs(binds: ReadonlyArray<string>): Promise<ReadonlyArray<WatchSpec>> { - const specs = new Map<string, WatchSpec>(); +function buildWatchSpecs(binds: ReadonlyArray<string>) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const specs = new Map<string, WatchSpec>(); - for (const bind of binds) { - const hostPath = dockerBindHostPath(bind); - if (!isAbsolute(hostPath)) { - continue; - } + for (const bind of binds) { + const hostPath = dockerBindHostPath(bind); + if (!isAbsolute(hostPath)) { + continue; + } - try { - const info = await stat(hostPath); - if (info.isDirectory()) { + const info = yield* fs.stat(hostPath).pipe(Effect.option); + if (Option.isNone(info)) { + continue; + } + if (info.value.type === "Directory") { specs.set(hostPath, { root: hostPath }); } else { const root = dirname(hostPath); @@ -1184,12 +1241,10 @@ async function buildWatchSpecs(binds: ReadonlyArray<string>): Promise<ReadonlyAr matchPaths.add(hostPath); specs.set(root, { root, matchPaths }); } - } catch { - continue; } - } - return [...specs.values()]; + return [...specs.values()]; + }); } function shouldIgnoreEvent(pathname: string) { @@ -1262,8 +1317,8 @@ const waitForRestartSignal = Effect.fnUntraced(function* (watchSpecs: ReadonlyAr }); }); -function forwardByteStream( - stream: Stream.Stream<Uint8Array, unknown>, +function forwardByteStream<E>( + stream: Stream.Stream<Uint8Array, E>, write: (text: string, stream: "stdout" | "stderr") => Effect.Effect<void>, streamName: "stdout" | "stderr", ) { @@ -1302,14 +1357,14 @@ const inspectContainerExitCode = Effect.fnUntraced(function* (containerId: strin if (result.exitCode !== 0) { const detail = result.stderr.trim() || result.stdout.trim() || "failed to inspect container"; - return yield* Effect.fail(new Error(detail)); + return yield* new FunctionsOperationError({ message: detail }); } const exitCode = Number.parseInt(result.stdout.trim(), 10); if (Number.isNaN(exitCode)) { - return yield* Effect.fail( - new Error(`failed to parse container exit code: ${result.stdout.trim()}`), - ); + return yield* new FunctionsOperationError({ + message: `failed to parse container exit code: ${result.stdout.trim()}`, + }); } return exitCode; @@ -1325,7 +1380,15 @@ const streamContainerLogs = Effect.fnUntraced(function* (containerId: string) { stdout: "pipe", stderr: "pipe", extendEnv: true, - }); + }).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); let stderrText = ""; const [exitCode] = yield* Effect.all( @@ -1347,20 +1410,24 @@ const streamContainerLogs = Effect.fnUntraced(function* (containerId: string) { if (exitCode === 0) { const containerExitCode = yield* inspectContainerExitCode(containerId); if (containerExitCode === 0) { - return yield* Effect.fail(new Error(`container exited gracefully: ${containerId}`)); + return yield* new FunctionsOperationError({ + message: `container exited gracefully: ${containerId}`, + }); } if (containerExitCode === 137) { yield* Effect.sleep(dockerLogRetryDelay); continue; } - return yield* Effect.fail(new Error(`error running container: exit ${containerExitCode}`)); + return yield* new FunctionsOperationError({ + message: `error running container: exit ${containerExitCode}`, + }); } const trimmedStderr = stderrText.trim(); if (!isRetriableDockerLogsError(trimmedStderr)) { - return yield* Effect.fail( - new Error(trimmedStderr.length > 0 ? trimmedStderr : `docker logs exited with ${exitCode}`), - ); + return yield* new FunctionsOperationError({ + message: trimmedStderr.length > 0 ? trimmedStderr : `docker logs exited with ${exitCode}`, + }); } yield* Effect.sleep(dockerLogRetryDelay); @@ -1389,7 +1456,7 @@ const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { } if (result.stderr.includes("No such container") || result.stderr.includes("No such object")) { - return yield* Effect.fail(new Error("supabase start is not running.")); + return yield* new FunctionsOperationError({ message: "supabase start is not running." }); } const message = @@ -1401,11 +1468,12 @@ const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { // `recoverAndExit` prints on its own stderr line after the red error // (`cmd/root.go:300-303`) — mirrored here by the `suggestion` property that // `normalizeCliError`/`Output.fail` render the same way. - return yield* Effect.fail( - legacyIsDockerDaemonUnreachable(result.stderr) - ? Object.assign(new Error(message), { suggestion: LEGACY_SUGGEST_DOCKER_INSTALL }) - : new Error(message), - ); + return yield* new FunctionsOperationError({ + message, + suggestion: legacyIsDockerDaemonUnreachable(result.stderr) + ? LEGACY_SUGGEST_DOCKER_INSTALL + : undefined, + }); }); const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: string) { @@ -1435,7 +1503,7 @@ const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( : detail.length > 0 ? `${opts.messagePrefix}: ${detail}` : opts.messagePrefix; - return yield* Effect.fail(new Error(message)); + return yield* new FunctionsOperationError({ message }); } }); @@ -1454,7 +1522,7 @@ const reloadKong = Effect.fnUntraced(function* (projectId: string) { "docker", ["exec", kongId, "kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"], { stdout: "ignore", stderr: "pipe" }, - ).pipe(Effect.catch(() => Effect.succeed({ exitCode: 1, stdout: "", stderr: "" }))); + ).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, stdout: "", stderr: "" }))); if (result.exitCode !== 0) { const suffix = result.stderr.trim().length > 0 ? ` ${result.stderr.trim()}` : ""; @@ -1528,6 +1596,7 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin importMapOverride: Option.Option<string>, noVerifyJwtOverride: Option.Option<boolean>, flagCwd: string, + projectEnvValues?: Readonly<Record<string, string>>, ) { const output = yield* Output; const functionConfigs = yield* resolveServeFunctionConfigs( @@ -1541,6 +1610,10 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin const functionsDir = join(projectRoot, functionsDirName); const binds = new Set<string>(); + const bitbucketCloneDir = + projectEnvValues === undefined + ? Option.getOrUndefined(yield* Config.option(Config.string("BITBUCKET_CLONE_DIR"))) + : projectEnvValues.BITBUCKET_CLONE_DIR; for (const fnConfig of functionConfigs) { if (!fnConfig.enabled) { @@ -1549,14 +1622,22 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { - additionalModuleRoots: [flagCwd], - skipMissingImportMapTargets: true, - onWarning: async (message) => { - bindWarnings.push(message); - }, - }), + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, fnConfig, { + additionalModuleRoots: [flagCwd], + skipMissingImportMapTargets: true, + bitbucketCloneDir, + onWarning: (message) => { + bindWarnings.push(message); + return Effect.void; + }, + }).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), )) { binds.add(bind); } @@ -1564,9 +1645,9 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin warning.includes("failed to read file:"), ); if (missingSourceWarning !== undefined) { - return yield* Effect.fail( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), - ); + return yield* new FunctionsOperationError({ + message: missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), + }); } } @@ -1615,10 +1696,16 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo // what lets the cleanup cover the whole staging-write window below, including a mid-write // failure between the first and second `writeDocker*` call, not just the final docker // create/cp/start steps. - const removeRuntimeArtifacts = Effect.tryPromise({ - try: () => rm(stagingDir, { recursive: true, force: true }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + const fs = yield* FileSystem.FileSystem; + const removeRuntimeArtifacts = fs.remove(stagingDir, { recursive: true, force: true }).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), + ); const bestEffortCleanupRuntimeArtifacts = removeRuntimeArtifacts.pipe( Effect.tapError((error) => output.warn(`Failed to clean up Edge Runtime artifacts: ${error.message}`), @@ -1633,11 +1720,24 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo input.importMap, input.noVerifyJwt, input.flagCwd, + ).pipe( + Effect.mapError((cause) => + cause instanceof FunctionsOperationError + ? cause + : new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), ); const functionsDir = join(input.projectRoot, functionsDirName); const functionBinds = new Set<string>(); const functionsConfig: Record<string, ServeFunctionContainerConfig> = {}; + const bitbucketCloneDir = + input.projectEnvValues === undefined + ? Option.getOrUndefined(yield* Config.option(Config.string("BITBUCKET_CLONE_DIR"))) + : input.projectEnvValues.BITBUCKET_CLONE_DIR; for (const config of functionConfigs) { if (!config.enabled) { @@ -1646,14 +1746,22 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo } const bindWarnings: string[] = []; - for (const bind of yield* Effect.promise(() => - buildDockerBinds(projectId, functionsDir, functionsDir, config, { - additionalModuleRoots: [input.flagCwd], - skipMissingImportMapTargets: true, - onWarning: async (message) => { - bindWarnings.push(message); - }, - }), + for (const bind of yield* buildDockerBinds(projectId, functionsDir, functionsDir, config, { + additionalModuleRoots: [input.flagCwd], + skipMissingImportMapTargets: true, + bitbucketCloneDir, + onWarning: (message) => { + bindWarnings.push(message); + return Effect.void; + }, + }).pipe( + Effect.mapError( + (cause) => + new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), )) { functionBinds.add(bind); } @@ -1661,9 +1769,9 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo warning.includes("failed to read file:"), ); if (missingSourceWarning !== undefined) { - return yield* Effect.fail( - new Error(missingSourceWarning.trimStart().replace(/^WARN:\s*/, "")), - ); + return yield* new FunctionsOperationError({ + message: missingSourceWarning.trimStart().replace(/^WARN:\s*/, ""), + }); } const functionEnv = input.discoverFunctionEnvFiles && Option.isNone(input.envFile) @@ -1678,7 +1786,11 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const binds = new Set(functionBinds); - yield* ensureDockerNamedVolume(localDockerId("edge_runtime", projectId), projectId); + yield* ensureDockerNamedVolume( + localDockerId("edge_runtime", projectId), + projectId, + input.projectEnvValues, + ); yield* ensureDockerNetwork(networkMode, projectId); const env = [ @@ -1697,7 +1809,7 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo `SUPABASE_INTERNAL_JWT_SECRET=${input.authArtifacts.jwtSecret}`, `SUPABASE_JWKS=${input.authArtifacts.jwks}`, `SUPABASE_INTERNAL_HOST_PORT=${input.config.apiPort}`, - `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${JSON.stringify(functionsConfig)}`, + `SUPABASE_INTERNAL_FUNCTIONS_CONFIG=${encodeJsonText(functionsConfig)}`, ...(input.debug ? ["SUPABASE_INTERNAL_DEBUG=true"] : []), ]; if (input.inspectMode !== undefined) { @@ -1713,22 +1825,19 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return yield* Effect.gen(function* () { yield* Effect.try({ try: () => validateDockerMultilineEnvNames(multilineDockerEnv), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); - const dockerEnvFile = yield* Effect.tryPromise({ - try: () => writeDockerEnvFile(singleLineDockerEnv, join(stagingDir, "env")), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), }); + const dockerEnvFile = yield* writeDockerEnvFile(singleLineDockerEnv, join(stagingDir, "env")); const multilineEnvDir = "/root/.supabase/multiline-env"; - const dockerMultilineEnvScript = yield* Effect.tryPromise({ - try: () => - writeDockerMultilineEnvScript( - multilineDockerEnv, - multilineEnvDir, - join(stagingDir, "multiline-env"), - ), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), - }); + const dockerMultilineEnvScript = yield* writeDockerMultilineEnvScript( + multilineDockerEnv, + multilineEnvDir, + join(stagingDir, "multiline-env"), + ); const labels = dockerProjectLabels(projectId); const runtimeCommand = [ @@ -1740,13 +1849,12 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ...buildFunctionsServeInspectArgs(input.inspectMode, input.inspectMain), ...(input.debug ? ["--verbose"] : []), ]; - const serveMainTemplate = yield* Effect.promise(() => getLegacyFunctionsServeMainTemplate()); + const serveMainTemplate = yield* getLegacyFunctionsServeMainTemplate(); // Streamed in via `docker cp` between create and start: embedding the template in the // `sh -c` argv hits Windows ENAMETOOLONG (#5711), and a single-file host bind mounts as // an empty directory on daemons that cannot see this host's filesystem (#6254, #4190). - const serveMainArchive = yield* Effect.tryPromise({ - try: () => containerArchiveBytes({ [serveMainContainerPath]: serveMainTemplate }), - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + const serveMainArchive = yield* containerArchiveBytes({ + [serveMainContainerPath]: serveMainTemplate, }); const containerProjectRoot = toDockerPath(input.projectRoot); const nofile = edgeRuntimeNofileUlimit(input.platform); @@ -1796,9 +1904,19 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo return { containerId, cleanup: removeRuntimeArtifacts.pipe(Effect.orDie), - watchSpecs: yield* Effect.promise(() => buildWatchSpecs([...functionBinds])), + watchSpecs: yield* buildWatchSpecs([...functionBinds]), } satisfies StartedRuntime; - }).pipe(Effect.onError(() => bestEffortCleanupRuntimeArtifacts)); + }).pipe( + Effect.onError(() => bestEffortCleanupRuntimeArtifacts), + Effect.catchTag("PlatformError", (cause) => + Effect.fail( + new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), + ), + ); }, ); @@ -1862,7 +1980,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { envOverride: resolved.projectEnvValues === undefined ? undefined - : legacyViperEnvStringWithProjectFallback( + : yield* legacyViperEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", resolved.projectEnvValues, ), @@ -1936,6 +2054,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { authArtifacts, dbUrl: legacyDefaultServeDbUrl, image, + projectEnvValues: resolved.projectEnvValues, projectRoot: input.dependencies.projectRoot, supabaseDir: input.dependencies.supabaseDir, flagCwd: input.dependencies.flagCwd, @@ -1948,7 +2067,16 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { noVerifyJwt: input.flags.noVerifyJwt, inspectMode: input.inspectMode, inspectMain: input.flags.inspectMain, - }); + }).pipe( + Effect.mapError((cause) => + cause instanceof FunctionsOperationError && findPlatformError(cause) === undefined + ? cause + : new FunctionsOperationError({ + message: legacyFilesystemErrorMessage(cause), + cause, + }), + ), + ); yield* reloadKong(projectId); @@ -1978,7 +2106,11 @@ export const serveFunctions = Effect.fn("functions.serve")(function* ( buildFunctionsServeInspectArgs(resolvedInspectMode, flags.inspectMain); return resolvedInspectMode; }, - catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + catch: (cause) => + new FunctionsOperationError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), }); const loop = Effect.gen(function* () { diff --git a/apps/cli/src/shared/functions/serve.unit.test.ts b/apps/cli/src/shared/functions/serve.unit.test.ts index cd4d3af2d6..1643be8244 100644 --- a/apps/cli/src/shared/functions/serve.unit.test.ts +++ b/apps/cli/src/shared/functions/serve.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { Effect } from "effect"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; import { dockerBindContainerPath } from "./deploy.ts"; @@ -16,12 +17,15 @@ describe("buildServeEntrypointCommand", () => { expect(script).toContain(". /root/env.sh\nedge-runtime start"); }); - it("keeps the spawned command short even with the real bundled template", async () => { - const bundled = await bundleServeMainTemplate(); - const script = buildServeEntrypointCommand(["edge-runtime", "start"]); - expect(bundled.length).toBeGreaterThan(20_000); - expect(script.length).toBeLessThan(128); - }); + it("keeps the spawned command short even with the real bundled template", () => + Effect.runPromise( + Effect.gen(function* () { + const bundled = yield* bundleServeMainTemplate(); + const script = buildServeEntrypointCommand(["edge-runtime", "start"]); + expect(bundled.length).toBeGreaterThan(20_000); + expect(script.length).toBeLessThan(128); + }), + )); }); describe("dockerBindContainerPath", () => { diff --git a/apps/cli/src/shared/git/git-branch.ts b/apps/cli/src/shared/git/git-branch.ts index 190917f514..74d8c40f40 100644 --- a/apps/cli/src/shared/git/git-branch.ts +++ b/apps/cli/src/shared/git/git-branch.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Option, Path } from "effect"; +import { Config, Effect, FileSystem, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; @@ -22,9 +22,11 @@ export const detectGitBranch = ( startDir?: string, ): Effect.Effect<Option.Option<string>, never, RuntimeInfo | FileSystem.FileSystem | Path.Path> => Effect.gen(function* () { - const githubHeadRef = process.env["GITHUB_HEAD_REF"]; - if (githubHeadRef !== undefined && githubHeadRef.length > 0) { - return Option.some(githubHeadRef); + const githubHeadRef = yield* Config.option(Config.string("GITHUB_HEAD_REF")).pipe( + Effect.orElseSucceed(() => Option.none<string>()), + ); + if (Option.isSome(githubHeadRef) && githubHeadRef.value.length > 0) { + return githubHeadRef; } const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/shared/git/git-branch.unit.test.ts b/apps/cli/src/shared/git/git-branch.unit.test.ts index 1c8640f8ca..1e6576fe42 100644 --- a/apps/cli/src/shared/git/git-branch.unit.test.ts +++ b/apps/cli/src/shared/git/git-branch.unit.test.ts @@ -1,17 +1,16 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, Exit, FileSystem, Layer, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; import { detectGitBranch } from "./git-branch.ts"; -function withCwd(cwd: string) { +function withCwd(cwd: string, env: Record<string, string> = {}) { return Layer.mergeAll( BunServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })), Layer.succeed(RuntimeInfo, { cwd, platform: process.platform, @@ -23,114 +22,94 @@ function withCwd(cwd: string) { ); } -describe("detectGitBranch", () => { - let original: string | undefined; +const makeTempDirectory = (prefix: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ directory: tmpdir(), prefix }); + }); + +const removeTempDirectory = (root: string, _exit: Exit.Exit<unknown, unknown>) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(root, { recursive: true }).pipe(Effect.ignore); + }); +const acquireTempDirectory = (prefix: string) => + Effect.acquireRelease(makeTempDirectory(prefix), removeTempDirectory); + +describe("detectGitBranch", () => { it.live("returns $GITHUB_HEAD_REF when set", () => { - original = process.env["GITHUB_HEAD_REF"]; - process.env["GITHUB_HEAD_REF"] = "ci-branch"; return Effect.gen(function* () { const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("ci-branch"); - } finally { - if (original === undefined) delete process.env["GITHUB_HEAD_REF"]; - else process.env["GITHUB_HEAD_REF"] = original; - } - }).pipe(Effect.provide(withCwd(tmpdir()))); + expect(Option.isSome(got)).toBe(true); + if (Option.isSome(got)) expect(got.value).toBe("ci-branch"); + }).pipe(Effect.provide(withCwd(tmpdir(), { GITHUB_HEAD_REF: "ci-branch" }))); }); - it.live("parses ref: refs/heads/<name> from .git/HEAD in CWD", () => { - const original2 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-")); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/feature-x\n"); + it.live("parses ref: refs/heads/<name> from .git/HEAD in the start directory", () => { return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("feature-x"); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original2 !== undefined) process.env["GITHUB_HEAD_REF"] = original2; - } - }).pipe(Effect.provide(withCwd(root))); + const root = yield* acquireTempDirectory("git-branch-"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(root, ".git")); + yield* fs.writeFileString(path.join(root, ".git", "HEAD"), "ref: refs/heads/feature-x\n"); + const got = yield* detectGitBranch(root); + expect(Option.isSome(got)).toBe(true); + if (Option.isSome(got)) expect(got.value).toBe("feature-x"); + }).pipe(Effect.provide(withCwd(tmpdir()))); }); it.live("walks up parent directories until .git/HEAD is found", () => { - const original3 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-walk-")); - const nested = join(root, "a", "b", "c"); - mkdirSync(nested, { recursive: true }); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "ref: refs/heads/main\n"); return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("main"); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original3 !== undefined) process.env["GITHUB_HEAD_REF"] = original3; - } - }).pipe(Effect.provide(withCwd(nested))); + const root = yield* acquireTempDirectory("git-branch-walk-"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nested = path.join(root, "a", "b", "c"); + yield* fs.makeDirectory(nested, { recursive: true }); + yield* fs.makeDirectory(path.join(root, ".git")); + yield* fs.writeFileString(path.join(root, ".git", "HEAD"), "ref: refs/heads/main\n"); + const got = yield* detectGitBranch(nested); + expect(Option.isSome(got)).toBe(true); + if (Option.isSome(got)) expect(got.value).toBe("main"); + }).pipe(Effect.provide(withCwd(tmpdir()))); }); - it.live("returns none when no .git/HEAD is ever found", () => { - const original4 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-empty-")); + it.live("returns none when no .git/HEAD is ever found and the env value is empty", () => { return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isNone(got)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original4 !== undefined) process.env["GITHUB_HEAD_REF"] = original4; - } - }).pipe(Effect.provide(withCwd(root))); + const root = yield* acquireTempDirectory("git-branch-empty-"); + const got = yield* detectGitBranch(root); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(withCwd(tmpdir(), { GITHUB_HEAD_REF: "" }))); }); it.live("returns none when .git/HEAD points at a detached commit (no ref: line)", () => { - const original5 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - const root = mkdtempSync(join(tmpdir(), "git-branch-detached-")); - mkdirSync(join(root, ".git")); - writeFileSync(join(root, ".git", "HEAD"), "deadbeef\n"); return Effect.gen(function* () { - const got = yield* detectGitBranch(); - try { - expect(Option.isNone(got)).toBe(true); - } finally { - rmSync(root, { recursive: true, force: true }); - if (original5 !== undefined) process.env["GITHUB_HEAD_REF"] = original5; - } - }).pipe(Effect.provide(withCwd(root))); + const root = yield* acquireTempDirectory("git-branch-detached-"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(root, ".git")); + yield* fs.writeFileString(path.join(root, ".git", "HEAD"), "deadbeef\n"); + const got = yield* detectGitBranch(root); + expect(Option.isNone(got)).toBe(true); + }).pipe(Effect.provide(withCwd(tmpdir()))); }); it.live("walks from an explicit startDir instead of the runtime CWD", () => { - const original6 = process.env["GITHUB_HEAD_REF"]; - delete process.env["GITHUB_HEAD_REF"]; - // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an - // unrelated dir with no repo, mirroring `supabase --workdir <project>` run - // from elsewhere. - const project = mkdtempSync(join(tmpdir(), "git-branch-workdir-")); - mkdirSync(join(project, ".git")); - writeFileSync(join(project, ".git", "HEAD"), "ref: refs/heads/project-branch\n"); - const elsewhere = mkdtempSync(join(tmpdir(), "git-branch-cwd-")); return Effect.gen(function* () { + // The project repo (with .git/HEAD) is the startDir; the runtime CWD is an + // unrelated dir with no repo, mirroring `supabase --workdir <project>` run + // from elsewhere. + const project = yield* acquireTempDirectory("git-branch-workdir-"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(project, ".git")); + yield* fs.writeFileString( + path.join(project, ".git", "HEAD"), + "ref: refs/heads/project-branch\n", + ); const got = yield* detectGitBranch(project); - try { - expect(Option.isSome(got)).toBe(true); - if (Option.isSome(got)) expect(got.value).toBe("project-branch"); - } finally { - rmSync(project, { recursive: true, force: true }); - rmSync(elsewhere, { recursive: true, force: true }); - if (original6 !== undefined) process.env["GITHUB_HEAD_REF"] = original6; - } - }).pipe(Effect.provide(withCwd(elsewhere))); + expect(Option.isSome(got)).toBe(true); + if (Option.isSome(got)) expect(got.value).toBe("project-branch"); + }).pipe(Effect.provide(withCwd(tmpdir()))); }); }); diff --git a/apps/cli/src/shared/git/git-root.ts b/apps/cli/src/shared/git/git-root.ts index 062fb14c4d..8627e686b7 100644 --- a/apps/cli/src/shared/git/git-root.ts +++ b/apps/cli/src/shared/git/git-root.ts @@ -1,21 +1,33 @@ -import { stat } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { Effect, FileSystem, Path } from "effect"; -export async function findGitRootPath(startPath: string) { - let current = resolve(startPath); +/** + * Finds the nearest ancestor containing a `.git` entry. + * + * Filesystem failures intentionally have the same semantics as the previous + * implementation: an unreadable or missing marker is treated as a miss and + * the search continues towards the filesystem root. + */ +export const findGitRootPath: ( + startPath: string, +) => Effect.Effect<string | undefined, never, FileSystem.FileSystem | Path.Path> = + Effect.fnUntraced(function* (startPath: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let current = path.resolve(startPath); - for (;;) { - try { - await stat(resolve(current, ".git")); - return current; - } catch { - // Keep walking until we hit the filesystem root. - } + for (;;) { + const hasGitMarker = yield* fs.stat(path.join(current, ".git")).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (hasGitMarker) { + return current; + } - const parent = dirname(current); - if (parent === current) { - return undefined; + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; } - current = parent; - } -} + }); diff --git a/apps/cli/src/shared/init/project-init.modes.integration.test.ts b/apps/cli/src/shared/init/project-init.modes.integration.test.ts index 27d019e8ec..43ce48fdc9 100644 --- a/apps/cli/src/shared/init/project-init.modes.integration.test.ts +++ b/apps/cli/src/shared/init/project-init.modes.integration.test.ts @@ -1,18 +1,10 @@ -import { mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { mockOutput, mockStdin, mockTty } from "../../../tests/helpers/mocks.ts"; import { initProject } from "./project-init.ts"; -function makeTempProjectDir(): string { - return mkdtempSync(join(tmpdir(), "supabase-init-modes-")); -} - function runInit(cwd: string) { const out = mockOutput({ format: "text", interactive: false }); // `initProject`'s type requires `Stdin` (the IDE-settings prompt path threads @@ -37,49 +29,43 @@ function runInit(cwd: string) { // incidental to the ambient umask. describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { it.live("pins the supabase dir and config.toml to Go's exact modes", () => { - const cwd = makeTempProjectDir(); - const prevUmask = process.umask(0); - - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const supabaseDir = join(cwd, "supabase"); - const configTomlPath = join(supabaseDir, "config.toml"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory({ prefix: "supabase-init-modes-" }); + const prevUmask = process.umask(0); + yield* Effect.gen(function* () { + yield* runInit(cwd); + const supabaseDir = path.join(cwd, "supabase"); + const configTomlPath = path.join(supabaseDir, "config.toml"); - expect(statSync(supabaseDir).mode & 0o777).toBe(0o755); - expect(statSync(configTomlPath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); + expect((yield* fs.stat(supabaseDir)).mode & 0o777).toBe(0o755); + expect((yield* fs.stat(configTomlPath)).mode & 0o777).toBe(0o644); + }).pipe( + Effect.ensuring(Effect.sync(() => process.umask(prevUmask))), + Effect.ensuring(fs.remove(cwd, { recursive: true }).pipe(Effect.ignore)), + ); + }).pipe(Effect.provide(BunServices.layer)); }); it.live( "pins a freshly created supabase/.gitignore to Go's exact file mode inside a git repo", () => { - const cwd = makeTempProjectDir(); - mkdirSync(join(cwd, ".git")); - const prevUmask = process.umask(0); - - return runInit(cwd).pipe( - Effect.andThen( - Effect.sync(() => { - const gitignorePath = join(cwd, "supabase", ".gitignore"); - expect(statSync(gitignorePath).mode & 0o777).toBe(0o644); - }), - ), - Effect.ensuring( - Effect.sync(() => { - process.umask(prevUmask); - rmSync(cwd, { recursive: true, force: true }); - }), - ), - ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory({ prefix: "supabase-init-modes-" }); + const prevUmask = process.umask(0); + yield* Effect.gen(function* () { + yield* fs.makeDirectory(path.join(cwd, ".git")); + yield* runInit(cwd); + const gitignorePath = path.join(cwd, "supabase", ".gitignore"); + expect((yield* fs.stat(gitignorePath)).mode & 0o777).toBe(0o644); + }).pipe( + Effect.ensuring(Effect.sync(() => process.umask(prevUmask))), + Effect.ensuring(fs.remove(cwd, { recursive: true }).pipe(Effect.ignore)), + ); + }).pipe(Effect.provide(BunServices.layer)); }, ); }); diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index e457528c67..b19f5d6a00 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -1,7 +1,7 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, FileSystem, Path } from "effect"; import { INIT_GITIGNORE_TEMPLATE, INTELLIJ_DENO_TEMPLATE, @@ -10,23 +10,42 @@ import { renderProjectConfigTemplate, } from "./project-init.templates.ts"; -const here = dirname(fileURLToPath(import.meta.url)); -const goCliRoot = join(here, "../../../../cli-go"); +const paths = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + const here = path.dirname(fileURLToPath(import.meta.url)); + return { + goCliRoot: path.join(here, "../../../../cli-go"), + goTemplatesFixtureDir: path.join(here, "testdata/go-templates"), + }; + }).pipe(Effect.provide(BunServices.layer)), +); +const { goCliRoot, goTemplatesFixtureDir } = paths; // Vendored copies of Go's `internal/init/templates/` scaffold files (deleted in // CLI-1970; last present at commit 7b469f5b3). The dotted file names are // de-dotted so git/tooling don't interpret the fixtures themselves. -const goTemplatesFixtureDir = join(here, "testdata/go-templates"); +const readUtf8 = (filePath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(filePath); + }); function normalizeNewlines(text: string): string { return text.replace(/\r\n/g, "\n"); } -function readGoTemplate(...segments: ReadonlyArray<string>): string { - return normalizeNewlines(readFileSync(join(goCliRoot, ...segments), "utf8")); +function readGoTemplate(...segments: ReadonlyArray<string>) { + return Effect.gen(function* () { + const path = yield* Path.Path; + return normalizeNewlines(yield* readUtf8(path.join(goCliRoot, ...segments))); + }); } -function readVendoredTemplate(name: string): string { - return normalizeNewlines(readFileSync(join(goTemplatesFixtureDir, name), "utf8")); +function readVendoredTemplate(name: string) { + return Effect.gen(function* () { + const path = yield* Path.Path; + return normalizeNewlines(yield* readUtf8(path.join(goTemplatesFixtureDir, name))); + }); } // Go renders its config.toml scaffold through text/template (config.Eject), so an action @@ -40,39 +59,51 @@ function resolveGoTemplateEscapes(template: string): string { } // Emulates what Go's config.Eject writes to disk for a fresh `supabase init` project. -function renderExpectedGoEject(): string { - return ( - resolveGoTemplateEscapes(readGoTemplate("pkg", "config", "templates", "config.toml")) - .replace("{{ .ProjectId }}", "demo-project") - .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") - // supabase init always opts new projects into pg-delta; the Go template renders - // this from a flag set only on the init path (false when deriving defaults). - .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") +function renderExpectedGoEject() { + return readGoTemplate("pkg", "config", "templates", "config.toml").pipe( + Effect.map((template) => + resolveGoTemplateEscapes(template) + .replace("{{ .ProjectId }}", "demo-project") + .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") + // supabase init always opts new projects into pg-delta; the Go template renders + // this from a flag set only on the init path (false when deriving defaults). + .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true"), + ), ); } -function renderExpectedNativeEject(): string { - return renderExpectedGoEject().replace( - '# content_path = "./templates/password_changed_notification.html"', - '# content_path = "./supabase/templates/password_changed_notification.html"', +function renderExpectedNativeEject() { + return renderExpectedGoEject().pipe( + Effect.map((template) => + template.replace( + '# content_path = "./templates/password_changed_notification.html"', + '# content_path = "./supabase/templates/password_changed_notification.html"', + ), + ), ); } describe("project init templates", () => { - it("renders config.toml with the native notification content_path base", () => { - expect(normalizeNewlines(renderProjectConfigTemplate("demo-project", true))).toBe( - renderExpectedNativeEject(), - ); - }); + it.effect("renders config.toml with the native notification content_path base", () => + Effect.gen(function* () { + expect(normalizeNewlines(renderProjectConfigTemplate("demo-project", true))).toBe( + yield* renderExpectedNativeEject(), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("models every template action in the Go scaffold, so parity cannot silently drift", () => { - // After escape resolution and field substitution, the only {{ ... }} occurrences left - // must be the GoTrue OTP placeholders quoted by the backtick escapes. Anything else - // means the Go template gained a construct this suite does not emulate yet — update - // resolveGoTemplateEscapes/renderExpectedGoEject to match config.Eject before shipping. - const unresolvedActions = renderExpectedGoEject().match(/\{\{[^}]*\}\}/g) ?? []; - expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); - }); + it.effect( + "models every template action in the Go scaffold, so parity cannot silently drift", + () => + Effect.gen(function* () { + // After escape resolution and field substitution, the only {{ ... }} occurrences left + // must be the GoTrue OTP placeholders quoted by the backtick escapes. Anything else + // means the Go template gained a construct this suite does not emulate yet — update + // resolveGoTemplateEscapes/renderExpectedGoEject to match config.Eject before shipping. + const unresolvedActions = (yield* renderExpectedGoEject()).match(/\{\{[^}]*\}\}/g) ?? []; + expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); + }).pipe(Effect.provide(BunServices.layer)), + ); it("renders the SMS and MFA phone OTP templates as GoTrue templates, not raw Go escapes", () => { const rendered = renderProjectConfigTemplate("demo-project", false); @@ -88,19 +119,31 @@ describe("project init templates", () => { expect(rendered).toContain("[experimental.pgdelta]\nenabled = true"); }); - it("matches the Go .gitignore scaffold", () => { - expect(INIT_GITIGNORE_TEMPLATE).toBe(readVendoredTemplate("gitignore")); - }); + it.effect("matches the Go .gitignore scaffold", () => + Effect.gen(function* () { + expect(INIT_GITIGNORE_TEMPLATE).toBe(yield* readVendoredTemplate("gitignore")); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code extensions scaffold", () => { - expect(VSCODE_EXTENSIONS_TEMPLATE).toBe(readVendoredTemplate("vscode-extensions.json.golden")); - }); + it.effect("matches the Go VS Code extensions scaffold", () => + Effect.gen(function* () { + expect(VSCODE_EXTENSIONS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-extensions.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go VS Code settings scaffold", () => { - expect(VSCODE_SETTINGS_TEMPLATE).toBe(readVendoredTemplate("vscode-settings.json.golden")); - }); + it.effect("matches the Go VS Code settings scaffold", () => + Effect.gen(function* () { + expect(VSCODE_SETTINGS_TEMPLATE).toBe( + yield* readVendoredTemplate("vscode-settings.json.golden"), + ); + }).pipe(Effect.provide(BunServices.layer)), + ); - it("matches the Go IntelliJ scaffold", () => { - expect(INTELLIJ_DENO_TEMPLATE).toBe(readVendoredTemplate("idea-deno.xml")); - }); + it.effect("matches the Go IntelliJ scaffold", () => + Effect.gen(function* () { + expect(INTELLIJ_DENO_TEMPLATE).toBe(yield* readVendoredTemplate("idea-deno.xml")); + }).pipe(Effect.provide(BunServices.layer)), + ); }); diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index c201c87988..d3811ad8ba 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Path, Schema } from "effect"; +import { Effect, FileSystem, Formatter, Path, Schema } from "effect"; import { legacyPromptYesNo } from "../legacy/legacy-prompt-yes-no.ts"; import { Output } from "../output/output.service.ts"; import { Tty } from "../runtime/tty.service.ts"; @@ -153,7 +153,7 @@ const INIT_DIR_MODE = 0o755; function writeJsonFile(pathname: string, contents: Record<string, unknown>) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(pathname, `${JSON.stringify(contents, null, 2)}\n`, { + yield* fs.writeFileString(pathname, `${Formatter.formatJson(contents, { space: 2 })}\n`, { mode: INIT_FILE_MODE, }); }); diff --git a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts index 8a1c82ce78..424cb8e61f 100644 --- a/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts +++ b/apps/cli/src/shared/issue/issue-template-contract.unit.test.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { parse } from "yaml"; import { buildIssueUrl, @@ -8,6 +8,8 @@ import { issueInstallMethodValues, issueTemplateContract, } from "./issue-url.ts"; +import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; +import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; type IssueFormOption = | string @@ -34,15 +36,22 @@ function isBodyItem(value: unknown): value is IssueFormBodyItem { return isRecord(value); } -function issueTemplateDir() { - return resolve(process.cwd(), "../../.github/ISSUE_TEMPLATE"); -} +const testLayer = Layer.mergeAll(BunServices.layer, runtimeInfoLayer); + +const issueTemplateDir = Effect.gen(function* () { + const runtimeInfo = yield* RuntimeInfo; + const path = yield* Path.Path; + return path.resolve(runtimeInfo.cwd, "../../.github/ISSUE_TEMPLATE"); +}); -function readTemplate(template: string): ReadonlyArray<IssueFormBodyItem> { - const path = resolve(issueTemplateDir(), template); - const parsed = parse(readFileSync(path, "utf8")); - if (!isRecord(parsed) || !Array.isArray(parsed.body)) return []; - return parsed.body.filter(isBodyItem); +function readTemplate(templateDir: string, template: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parsed = parse(yield* fs.readFileString(path.resolve(templateDir, template))); + if (!isRecord(parsed) || !Array.isArray(parsed.body)) return []; + return parsed.body.filter(isBodyItem); + }); } function fieldIds(body: ReadonlyArray<IssueFormBodyItem>) { @@ -67,42 +76,53 @@ function requiredFields(body: ReadonlyArray<IssueFormBodyItem>) { const options = item.attributes?.options; if (!Array.isArray(options) || typeof item.id !== "string") return []; + const fieldId = item.id; return options.flatMap((option: IssueFormOption) => { if (typeof option === "string") return []; - return option.required === true ? [`${item.id}:${String(option.label)}`] : []; + return option.required === true ? [`${fieldId}:${String(option.label)}`] : []; }); }); } describe("issue template contract", () => { - it("points to issue form templates that exist", () => { - for (const form of Object.values(issueTemplateContract)) { - expect(existsSync(resolve(issueTemplateDir(), form.template))).toBe(true); - } - }); - - it("keeps issue command field ids aligned with the GitHub issue forms", () => { - for (const form of Object.values(issueTemplateContract)) { - const ids = fieldIds(readTemplate(form.template)); - expect(ids).toEqual(expect.arrayContaining([...form.fields])); - expect(form.fields).toEqual(expect.arrayContaining(ids)); - } - }); - - it("keeps issue command prefilled option values valid for their fields", () => { - for (const form of Object.values(issueTemplateContract)) { - const body = readTemplate(form.template); - for (const [fieldId, values] of Object.entries(form.optionValues)) { - const item = body.find((entry) => entry.id === fieldId); - expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); - expect(optionLabels(item!)).toEqual(expect.arrayContaining([...values])); + it.effect("points to issue form templates that exist", () => + Effect.gen(function* () { + const templateDir = yield* issueTemplateDir; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const form of Object.values(issueTemplateContract)) { + expect(yield* fs.exists(path.resolve(templateDir, form.template))).toBe(true); } - } - }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps issue command field ids aligned with the GitHub issue forms", () => + Effect.gen(function* () { + const templateDir = yield* issueTemplateDir; + for (const form of Object.values(issueTemplateContract)) { + const ids = fieldIds(yield* readTemplate(templateDir, form.template)); + expect(ids).toEqual(expect.arrayContaining([...form.fields])); + expect(form.fields).toEqual(expect.arrayContaining(ids)); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps issue command prefilled option values valid for their fields", () => + Effect.gen(function* () { + const templateDir = yield* issueTemplateDir; + for (const form of Object.values(issueTemplateContract)) { + const body = yield* readTemplate(templateDir, form.template); + for (const [fieldId, values] of Object.entries(form.optionValues)) { + const item = body.find((entry) => entry.id === fieldId); + expect(item, `${form.template} should include field ${fieldId}`).toBeDefined(); + if (item === undefined) continue; + expect(optionLabels(item)).toEqual(expect.arrayContaining([...values])); + } + } + }).pipe(Effect.provide(testLayer)), + ); it("keeps inferred install methods compatible with the template dropdown", () => { - const originalUserAgent = process.env["npm_config_user_agent"]; - const originalInstallMethod = process.env["SUPABASE_INSTALL_METHOD"]; const cases = [ { userAgent: "pnpm/10.0.0", execPath: "/usr/local/bin/supabase", expected: "pnpm" }, { userAgent: "npm/11.0.0", execPath: "/usr/local/bin/supabase", expected: "npm" }, @@ -112,30 +132,27 @@ describe("issue template contract", () => { { userAgent: undefined, execPath: "/usr/local/bin/supabase", expected: "Other" }, ] as const; - try { - delete process.env["SUPABASE_INSTALL_METHOD"]; - for (const testcase of cases) { - if (testcase.userAgent === undefined) { - delete process.env["npm_config_user_agent"]; - } else { - process.env["npm_config_user_agent"] = testcase.userAgent; - } - const value = inferIssueInstallMethod({ execPath: testcase.execPath }); - expect(value).toBe(testcase.expected); - expect(issueInstallMethodValues).toContain(value); - } - - process.env["SUPABASE_INSTALL_METHOD"] = "Docker image"; - expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Docker image"); - - process.env["SUPABASE_INSTALL_METHOD"] = "asdf"; - expect(inferIssueInstallMethod({ execPath: "/usr/local/bin/supabase" })).toBe("Other"); - } finally { - if (originalUserAgent === undefined) delete process.env["npm_config_user_agent"]; - else process.env["npm_config_user_agent"] = originalUserAgent; - if (originalInstallMethod === undefined) delete process.env["SUPABASE_INSTALL_METHOD"]; - else process.env["SUPABASE_INSTALL_METHOD"] = originalInstallMethod; + for (const testcase of cases) { + const value = inferIssueInstallMethod( + { execPath: testcase.execPath }, + { npm_config_user_agent: testcase.userAgent }, + ); + expect(value).toBe(testcase.expected); + expect(issueInstallMethodValues).toContain(value); } + + expect( + inferIssueInstallMethod( + { execPath: "/usr/local/bin/supabase" }, + { SUPABASE_INSTALL_METHOD: "Docker image" }, + ), + ).toBe("Docker image"); + expect( + inferIssueInstallMethod( + { execPath: "/usr/local/bin/supabase" }, + { SUPABASE_INSTALL_METHOD: "asdf" }, + ), + ).toBe("Other"); }); it("keeps generated issue URLs under the browser-friendly limit", () => { @@ -150,9 +167,13 @@ describe("issue template contract", () => { expect(url.length).toBeLessThanOrEqual(8_000); }); - it("keeps issue form required fields aligned with the command contract", () => { - for (const form of Object.values(issueTemplateContract)) { - expect(requiredFields(readTemplate(form.template))).toEqual([...form.requiredFields]); - } - }); + it.effect("keeps issue form required fields aligned with the command contract", () => + Effect.gen(function* () { + const templateDir = yield* issueTemplateDir; + for (const form of Object.values(issueTemplateContract)) { + const body = yield* readTemplate(templateDir, form.template); + expect(requiredFields(body)).toEqual([...form.requiredFields]); + } + }).pipe(Effect.provide(testLayer)), + ); }); diff --git a/apps/cli/src/shared/issue/issue-url.ts b/apps/cli/src/shared/issue/issue-url.ts index 2c24d5ca6c..94c0ce3106 100644 --- a/apps/cli/src/shared/issue/issue-url.ts +++ b/apps/cli/src/shared/issue/issue-url.ts @@ -134,11 +134,14 @@ function validInstallMethod(value: string): string { return issueInstallMethodValueSet.has(value) ? value : "Other"; } -export function inferIssueInstallMethod(runtimeInfo: { readonly execPath: string }): string { - const explicit = process.env["SUPABASE_INSTALL_METHOD"]?.trim(); +export function inferIssueInstallMethod( + runtimeInfo: { readonly execPath: string }, + env: Readonly<Record<string, string | undefined>>, +): string { + const explicit = env["SUPABASE_INSTALL_METHOD"]?.trim(); if (explicit) return validInstallMethod(explicit); - const userAgent = process.env["npm_config_user_agent"]?.toLowerCase(); + const userAgent = env["npm_config_user_agent"]?.toLowerCase(); if (userAgent?.startsWith("pnpm/")) return "pnpm"; if (userAgent?.startsWith("npm/")) return "npm"; if (userAgent?.startsWith("yarn/")) return "yarn"; diff --git a/apps/cli/src/shared/legacy/global-flags.ts b/apps/cli/src/shared/legacy/global-flags.ts index c1586e5f7b..544c14cffd 100644 --- a/apps/cli/src/shared/legacy/global-flags.ts +++ b/apps/cli/src/shared/legacy/global-flags.ts @@ -265,7 +265,7 @@ export const legacyResolveYes = Effect.gen(function* () { if (legacyYesFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || legacyViperEnvBool("SUPABASE_YES"); + return flag || (yield* legacyViperEnvBool("SUPABASE_YES")); }); /** @@ -287,7 +287,7 @@ export const legacyResolveYesWithProjectEnv = (projectEnv: Record<string, string if (legacyYesFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv); + return flag || (yield* legacyViperEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv)); }); /** @@ -340,7 +340,7 @@ export const legacyResolveExperimental = Effect.gen(function* () { if (explicit !== undefined) { return explicit; } - return flag || legacyViperEnvBool("SUPABASE_EXPERIMENTAL"); + return flag || (yield* legacyViperEnvBool("SUPABASE_EXPERIMENTAL")); }); /** @@ -364,7 +364,9 @@ export const legacyResolveExperimentalWithProjectEnv = (projectEnv: Record<strin if (explicit !== undefined) { return explicit; } - return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_EXPERIMENTAL", projectEnv); + return ( + flag || (yield* legacyViperEnvBoolWithProjectFallback("SUPABASE_EXPERIMENTAL", projectEnv)) + ); }); /** @@ -428,5 +430,5 @@ export const legacyResolveDebugWithProjectEnv = (projectEnv: Record<string, stri if (legacyDebugFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || legacyViperEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv); + return flag || (yield* legacyViperEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv)); }); diff --git a/apps/cli/src/shared/legacy/global-flags.unit.test.ts b/apps/cli/src/shared/legacy/global-flags.unit.test.ts index 561c365522..396d646e33 100644 --- a/apps/cli/src/shared/legacy/global-flags.unit.test.ts +++ b/apps/cli/src/shared/legacy/global-flags.unit.test.ts @@ -16,6 +16,7 @@ import { legacyGlobalFlagValues, legacyResolveDebugWithProjectEnv, } from "./global-flags.ts"; +import { legacyViperEnvLayer } from "./legacy-viper-env.ts"; describe("legacyGlobalFlagValues", () => { it.live( @@ -96,6 +97,7 @@ describe("legacyResolveDebugWithProjectEnv", () => { const layer = Layer.mergeAll( Layer.succeed(LegacyDebugFlag, true), Layer.succeed(CliArgs, { args: ["db", "pull", "--", "--debug=false"] }), + legacyViperEnvLayer, ); return legacyResolveDebugWithProjectEnv({}).pipe( Effect.provide(layer), @@ -118,6 +120,7 @@ describe("legacyResolveDebugWithProjectEnv", () => { const layer = Layer.mergeAll( Layer.succeed(LegacyDebugFlag, true), Layer.succeed(CliArgs, { args: ["db", "pull", "--password", "--debug=false"] }), + legacyViperEnvLayer, ); return legacyResolveDebugWithProjectEnv({}).pipe( Effect.provide(layer), diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.ts b/apps/cli/src/shared/legacy/go-proxy.layer.ts index 2dc40cba6c..88d763bc62 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.ts @@ -1,9 +1,8 @@ -import { existsSync } from "node:fs"; +import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { createRequire } from "node:module"; import os from "node:os"; -import path from "node:path"; import process from "node:process"; -import { Effect, Layer, Option, Stream } from "effect"; +import { Config, Effect, FileSystem, Layer, Option, Path, PlatformError, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { CLI_VERSION } from "../cli/version.ts"; @@ -44,11 +43,13 @@ export type BinaryResolution = | { readonly found: string } | { readonly notFound: ReadonlyArray<string> }; -function resolveBinary(): BinaryResolution { +const resolveBinary = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const tried: string[] = []; - const envBin = process.env["SUPABASE_GO_BINARY"]; - if (envBin) return { found: envBin }; + const envBin = yield* Config.option(Config.string("SUPABASE_GO_BINARY")); + if (Option.isSome(envBin) && envBin.value.length > 0) return { found: envBin.value }; tried.push("$SUPABASE_GO_BINARY (unset)"); const ext = process.platform === "win32" ? ".exe" : ""; @@ -56,25 +57,28 @@ function resolveBinary(): BinaryResolution { // When running as a compiled standalone SFE (exec'd by the base shim via execFileSync), // process.execPath is the SFE binary path. Look for supabase-go co-located next to it. const colocated = path.join(path.dirname(process.execPath), `supabase-go${ext}`); - if (existsSync(colocated)) return { found: colocated }; + if (yield* fs.exists(colocated)) return { found: colocated }; tried.push(`${colocated} (not found alongside the shim)`); // When running from source, resolve via installed npm packages. - // Guard with existsSync — in dev the workspace stub packages exist but their bin/ is empty. + // Guard with FileSystem.exists — in dev the workspace stub packages exist but their bin/ is empty. const candidates = PLATFORM_CANDIDATES[process.platform]?.[os.arch()] ?? []; for (const suffix of candidates) { - try { - const pkgPath = path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`)); - const bin = path.join(pkgPath, "bin", `supabase-go${ext}`); - if (existsSync(bin)) return { found: bin }; - tried.push(`${bin} (npm package present, binary missing)`); - } catch { + const pkgPath = yield* Effect.try({ + try: () => path.dirname(require.resolve(`@supabase/cli-${suffix}/package.json`)), + catch: () => undefined, + }).pipe(Effect.option); + if (Option.isNone(pkgPath)) { tried.push(`@supabase/cli-${suffix} (npm package not installed)`); + continue; } + const bin = path.join(pkgPath.value, "bin", `supabase-go${ext}`); + if (yield* fs.exists(bin)) return { found: bin }; + tried.push(`${bin} (npm package present, binary missing)`); } return { notFound: tried }; -} +}).pipe(Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layer))); /** * Build a concrete `curl | tar` install snippet for the host platform, using @@ -161,7 +165,11 @@ export function makeGoProxyLayer(opts?: { * artifact for the host platform. */ binary?: string | BinaryResolution; -}): Layer.Layer<LegacyGoProxy, never, ProcessControl | ChildProcessSpawner> { +}): Layer.Layer< + LegacyGoProxy, + Config.ConfigError | PlatformError.PlatformError, + ProcessControl | ChildProcessSpawner +> { return Layer.effect( LegacyGoProxy, Effect.gen(function* () { @@ -170,7 +178,7 @@ export function makeGoProxyLayer(opts?: { const resolved: BinaryResolution = typeof opts?.binary === "string" ? { found: opts.binary } - : (opts?.binary ?? resolveBinary()); + : (opts?.binary ?? (yield* resolveBinary)); const globalArgs = opts?.globalArgs ?? []; return LegacyGoProxy.of({ @@ -185,12 +193,10 @@ export function makeGoProxyLayer(opts?: { yield* Effect.sync(() => { process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); }); - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode: 1, - message: "supabase-go binary not found", - }), - ); + return yield* new LegacyGoChildExitError({ + exitCode: 1, + message: "supabase-go binary not found", + }); } const binary = resolved.found; @@ -238,12 +244,10 @@ export function makeGoProxyLayer(opts?: { }); const exitCode = yield* spawner.exitCode(command).pipe(Effect.orDie); if (exitCode !== 0) { - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode, - message: `supabase-go exited with code ${exitCode} (see stderr for details)`, - }), - ); + return yield* new LegacyGoChildExitError({ + exitCode, + message: `supabase-go exited with code ${exitCode} (see stderr for details)`, + }); } yield* markDelegated; }), @@ -255,12 +259,10 @@ export function makeGoProxyLayer(opts?: { yield* Effect.sync(() => { process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); }); - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode: 1, - message: "supabase-go binary not found", - }), - ); + return yield* new LegacyGoChildExitError({ + exitCode: 1, + message: "supabase-go binary not found", + }); } const binary = resolved.found; yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); @@ -299,12 +301,10 @@ export function makeGoProxyLayer(opts?: { ); const exitCode = yield* handle.exitCode.pipe(Effect.orDie); if (exitCode !== 0) { - return yield* Effect.fail( - new LegacyGoChildExitError({ - exitCode, - message: `supabase-go exited with code ${exitCode} (see stderr for details)`, - }), - ); + return yield* new LegacyGoChildExitError({ + exitCode, + message: `supabase-go exited with code ${exitCode} (see stderr for details)`, + }); } if (opts?.parentOwnsCapturedSuccessTail !== true) { yield* markDelegated; diff --git a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts index 2084861bc4..b8ab89d72c 100644 --- a/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/shared/legacy/go-proxy.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { Cause, Data, Deferred, Effect, Exit, Fiber, Layer, Sink, Stream } from "effect"; +import { PlatformError, SystemError } from "effect/PlatformError"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CliProcessSignal, ProcessControl } from "../runtime/process-control.service.ts"; import { LegacyGoChildExitError } from "./legacy-go-child-exit.error.ts"; import { GoProxyInvocation, goProxyInvocationLayer } from "./go-proxy-invocation.ts"; @@ -31,15 +32,7 @@ import { formatGoBinaryNotFoundError, makeGoProxyLayer } from "./go-proxy.layer. type CapturedCommand = { command: string; args: readonly string[]; - options: { - detached?: boolean; - stdin?: unknown; - stdout?: unknown; - stderr?: unknown; - cwd?: string; - env?: Record<string, string>; - extendEnv?: boolean; - }; + options: ChildProcess.CommandOptions; }; type ExitBehavior = @@ -99,7 +92,7 @@ function mockProcessControl() { ).pipe(Effect.asVoid), exit, setExitCode: () => Effect.void, - getExitCode: Effect.succeed(undefined), + getExitCode: Effect.as(Effect.void, undefined), }), ), }; @@ -114,13 +107,15 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred<v const spawned: CapturedCommand[] = []; const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command: any) => - Effect.sync(() => { - const cmd = command as CapturedCommand & { _tag: string }; + ChildProcessSpawner.make((command: ChildProcess.Command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") { + return yield* Effect.die("go proxy test received a piped command"); + } spawned.push({ - command: cmd.command, - args: cmd.args, - options: cmd.options, + command: command.command, + args: command.args, + options: command.options, }); if (spawnedBeforeExit !== undefined) { Deferred.doneUnsafe(spawnedBeforeExit, Effect.void); @@ -130,18 +125,27 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred<v ? Effect.succeed(ChildProcessSpawner.ExitCode(exit.code)) : exit.kind === "never" ? Effect.never - : Effect.fail(new Error(exit.error) as any); + : Effect.fail( + new PlatformError( + new SystemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: exit.error, + }), + ), + ); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(42_424), exitCode, isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), - stdin: Sink.drain as any, + stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, all: Stream.empty, - getInputFd: () => Sink.drain as any, + getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); }), @@ -193,69 +197,84 @@ describe("formatGoBinaryNotFoundError - pinned snippet", () => { const TRIED = ["$SUPABASE_GO_BINARY (unset)"]; const PINNED_VERSION = "2.100.0"; - async function withMockedHost( + class PinnedHostImportError extends Data.TaggedError("PinnedHostImportError")<{ + readonly message: string; + }> {} + + function withMockedHost( opts: { platform: NodeJS.Platform; arch: NodeJS.Architecture }, - fn: (mod: typeof import("./go-proxy.layer.ts")) => void | Promise<void>, - ): Promise<void> { - vi.resetModules(); - vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); - const originalPlatform = process.platform; - const originalArch = process.arch; - Object.defineProperty(process, "platform", { value: opts.platform, configurable: true }); - Object.defineProperty(process, "arch", { value: opts.arch, configurable: true }); - try { - const mod = await import("./go-proxy.layer.ts"); - await fn(mod); - } finally { - Object.defineProperty(process, "platform", { - value: originalPlatform, - configurable: true, - }); - Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); - vi.doUnmock("../cli/version.ts"); - vi.resetModules(); - } + fn: (mod: typeof import("./go-proxy.layer.ts")) => void, + ): Effect.Effect<void, PinnedHostImportError> { + return Effect.acquireUseRelease( + Effect.sync(() => { + vi.resetModules(); + vi.doMock("../cli/version.ts", () => ({ CLI_VERSION: PINNED_VERSION })); + const originalPlatform = process.platform; + const originalArch = process.arch; + Object.defineProperty(process, "platform", { value: opts.platform, configurable: true }); + Object.defineProperty(process, "arch", { value: opts.arch, configurable: true }); + return { originalPlatform, originalArch }; + }), + () => + Effect.tryPromise({ + try: () => import("./go-proxy.layer.ts"), + catch: (cause) => + new PinnedHostImportError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }).pipe(Effect.tap((mod) => Effect.sync(() => fn(mod)))), + ({ originalPlatform, originalArch }) => + Effect.sync(() => { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(process, "arch", { value: originalArch, configurable: true }); + vi.doUnmock("../cli/version.ts"); + vi.resetModules(); + }), + ); } - it("renders a copy-pasteable install snippet for linux x64", async () => { - await withMockedHost({ platform: "linux", arch: "x64" }, (mod) => { + it.effect("renders a copy-pasteable install snippet for linux x64", () => + withMockedHost({ platform: "linux", arch: "x64" }, (mod) => { const message = mod.formatGoBinaryNotFoundError(TRIED); expect(message).toContain( `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_linux_amd64.tar.gz`, ); expect(message).toContain(`mkdir -p "$HOME/.local/share/supabase"`); expect(message).toContain(`export PATH="$HOME/.local/share/supabase:$PATH"`); - }); - }); + }), + ); - it("maps Node's win32 platform to the release asset's `windows` slug", async () => { + it.effect("maps Node's win32 platform to the release asset's `windows` slug", () => // Release pipeline publishes `.tar.gz` for every (platform, arch) pair, // Windows included, so the snippet renders on win32 too — just with the // modern `windows` slug instead of Node's historical `win32`. - await withMockedHost({ platform: "win32", arch: "x64" }, (mod) => { + withMockedHost({ platform: "win32", arch: "x64" }, (mod) => { const message = mod.formatGoBinaryNotFoundError(TRIED); expect(message).toContain( `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_windows_amd64.tar.gz`, ); // Never emit Node's internal `win32` token in the user-facing URL. expect(message).not.toContain("win32"); - }); - }); + }), + ); - it("maps darwin arm64 to the matching release asset", async () => { - await withMockedHost({ platform: "darwin", arch: "arm64" }, (mod) => { + it.effect("maps darwin arm64 to the matching release asset", () => + withMockedHost({ platform: "darwin", arch: "arm64" }, (mod) => { expect(mod.formatGoBinaryNotFoundError(TRIED)).toContain( `supabase_${PINNED_VERSION}_darwin_arm64.tar.gz`, ); - }); - }); + }), + ); - it("omits the snippet on unsupported architectures (no release asset)", async () => { + it.effect("omits the snippet on unsupported architectures (no release asset)", () => // ia32 has never been a release target — the snippet should not invent a URL. - await withMockedHost({ platform: "linux", arch: "ia32" }, (mod) => { + withMockedHost({ platform: "linux", arch: "ia32" }, (mod) => { expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL"); - }); - }); + }), + ); }); describe("makeGoProxyLayer", () => { diff --git a/apps/cli/src/shared/legacy/legacy-filesystem-error.ts b/apps/cli/src/shared/legacy/legacy-filesystem-error.ts new file mode 100644 index 0000000000..8833efd5a4 --- /dev/null +++ b/apps/cli/src/shared/legacy/legacy-filesystem-error.ts @@ -0,0 +1,65 @@ +import { PlatformError, Predicate } from "effect"; + +function platformCauseCode(cause: unknown): string | undefined { + if (typeof cause !== "object" || cause === null || !("code" in cause)) { + return undefined; + } + const code = cause.code; + return typeof code === "string" ? code : undefined; +} + +export function findPlatformError(cause: unknown): PlatformError.PlatformError | undefined { + if (cause instanceof PlatformError.PlatformError) { + return cause; + } + if (typeof cause === "object" && cause !== null && "cause" in cause) { + return findPlatformError(cause.cause); + } + return undefined; +} + +/** + * Preserve the host filesystem wording at the Effect platform boundary. + * PlatformError keeps the original runtime error under reason.cause; callers + * otherwise only see normalized tags such as NotFound or BadResource. + */ +export function legacyFilesystemErrorMessage(cause: unknown): string { + const platformError = findPlatformError(cause); + if (platformError === undefined) { + return cause instanceof Error ? cause.message : String(cause); + } + + const reason = platformError.reason; + const original = reason.cause; + const originalMessage = original instanceof Error ? original.message : undefined; + if (originalMessage !== undefined && originalMessage.length > 0) { + return originalMessage; + } + + if (Predicate.isTagged(reason, "NotFound")) { + return "ENOENT: no such file or directory"; + } + if (Predicate.isTagged(reason, "PermissionDenied")) { + return "EACCES: permission denied"; + } + if (Predicate.isTagged(reason, "BadResource")) { + const code = platformCauseCode(original); + if (code === "EISDIR") { + return "EISDIR: illegal operation on a directory"; + } + if (code === "ENOTDIR") { + return "ENOTDIR: not a directory"; + } + if (reason.method === "readDirectory" || reason.syscall === "scandir") { + return `ENOTDIR: not a directory${ + reason.pathOrDescriptor === undefined ? "" : ` (${String(reason.pathOrDescriptor)})` + }`; + } + if (reason.method === "readFile" || reason.method === "readFileString") { + return `EISDIR: illegal operation on a directory${ + reason.pathOrDescriptor === undefined ? "" : ` (${String(reason.pathOrDescriptor)})` + }`; + } + } + return platformError.message; +} diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.ts b/apps/cli/src/shared/legacy/legacy-viper-env.ts index 03fa22be15..d221c2bfa3 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.ts @@ -23,17 +23,91 @@ * parsed flag value with this read (flag-set wins, matching viper precedence). */ +import { Config, ConfigProvider, Context, Effect, Layer, Match, Option } from "effect"; + const LEGACY_VIPER_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +interface LegacyViperEnvShape { + readonly get: (name: string) => Effect.Effect<Option.Option<string>, Config.ConfigError>; + readonly entries: ( + prefix: string, + ) => Effect.Effect<Readonly<Record<string, string>>, Config.ConfigError>; +} + +/** + * Environment view used by the legacy viper compatibility helpers. + * + * The provider is deliberately injected rather than read from Effect's ambient + * default. Viper treats an explicitly empty shell variable as present, while + * the default Effect environment provider treats empty strings as missing. + */ +export class LegacyViperEnv extends Context.Service<LegacyViperEnv, LegacyViperEnvShape>()( + "supabase/legacy/LegacyViperEnv", +) {} + +export const makeLegacyViperEnvLayer = ( + provider: ConfigProvider.ConfigProvider = ConfigProvider.fromEnv({ + preserveEmptyStrings: true, + }), +): Layer.Layer<LegacyViperEnv> => + Layer.succeed(LegacyViperEnv, { + get: (name) => + Config.option(Config.string(name)).pipe( + Effect.provideService(ConfigProvider.ConfigProvider, provider), + ), + entries: (prefix) => + Effect.gen(function* () { + const output: Record<string, string> = {}; + const basePath = prefix.split("_"); + const load = (path: ReadonlyArray<string>) => + provider.load(path).pipe(Effect.mapError((cause) => new Config.ConfigError(cause))); + const visit = ( + path: ReadonlyArray<string>, + node: ConfigProvider.Node | undefined, + ): Effect.Effect<void, Config.ConfigError> => + Effect.gen(function* () { + if (node === undefined) return; + if (node.value !== undefined) output[path.join("_")] = node.value; + const children = Match.valueTags(node, { + Value: () => [], + Record: ({ keys }) => [...keys], + Array: ({ length }) => Array.from({ length }, (_, index) => String(index)), + }); + for (const child of children) { + const childPath = [...path, child]; + yield* visit(childPath, yield* load(childPath)); + } + }); + yield* visit(basePath, yield* load(basePath)); + return output; + }), + }); + +/** Production boundary layer. Tests should use `makeLegacyViperEnvLayer` with a fixed provider. */ +export const legacyViperEnvLayer = makeLegacyViperEnvLayer(); + /** `viper.GetBool` truthiness for an already-resolved env value (see module doc). */ function legacyViperBool(raw: string | undefined): boolean { return raw !== undefined && LEGACY_VIPER_TRUE.has(raw); } -/** `viper.GetBool` for a single `SUPABASE_*` env var read from `process.env` (see module doc). */ -export function legacyViperEnvBool(name: string): boolean { - return legacyViperBool(process.env[name]); -} +/** `viper.GetBool` for a single `SUPABASE_*` env var from the injected Effect ConfigProvider. */ +export const legacyViperEnvBool = ( + name: string, +): Effect.Effect<boolean, Config.ConfigError, LegacyViperEnv> => + Effect.gen(function* () { + const env = yield* LegacyViperEnv; + return legacyViperBool(Option.getOrUndefined(yield* env.get(name))); + }); + +/** Enumerate exact environment keys rooted at a prefix (for example `DOTENV_PRIVATE_KEY`). */ +export const legacyViperEnvEntries = ( + prefix: string, +): Effect.Effect<Readonly<Record<string, string>>, Config.ConfigError, LegacyViperEnv> => + Effect.gen(function* () { + const env = yield* LegacyViperEnv; + return yield* env.entries(prefix); + }); /** * `viper.GetBool` for a `SUPABASE_*` key where a project `supabase/.env` value may also @@ -52,8 +126,11 @@ export function legacyViperEnvBool(name: string): boolean { export function legacyViperEnvBoolWithProjectFallback( name: string, projectEnv: Record<string, string>, -): boolean { - return legacyViperBool(process.env[name] ?? projectEnv[name]); +): Effect.Effect<boolean, Config.ConfigError, LegacyViperEnv> { + return Effect.gen(function* () { + const env = yield* LegacyViperEnv; + return legacyViperBool(Option.getOrElse(yield* env.get(name), () => projectEnv[name])); + }); } /** @@ -68,6 +145,9 @@ export function legacyViperEnvBoolWithProjectFallback( export function legacyViperEnvStringWithProjectFallback( name: string, projectEnv: Record<string, string>, -): string { - return process.env[name] ?? projectEnv[name] ?? ""; +): Effect.Effect<string, Config.ConfigError, LegacyViperEnv> { + return Effect.gen(function* () { + const env = yield* LegacyViperEnv; + return Option.getOrElse(yield* env.get(name), () => projectEnv[name] ?? ""); + }); } diff --git a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts index c67befd1d8..60115469f1 100644 --- a/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts +++ b/apps/cli/src/shared/legacy/legacy-viper-env.unit.test.ts @@ -1,112 +1,139 @@ -import { afterEach, describe, expect, it } from "vitest"; - +import { describe, expect, it } from "@effect/vitest"; +import { ConfigProvider, Effect } from "effect"; import { + makeLegacyViperEnvLayer, legacyViperEnvBool, legacyViperEnvBoolWithProjectFallback, + legacyViperEnvEntries, legacyViperEnvStringWithProjectFallback, } from "./legacy-viper-env.ts"; const KEY = "SUPABASE_TEST_VIPER_BOOL"; const STRING_KEY = "SUPABASE_TEST_VIPER_STRING"; +const PRIVATE_KEY_PREFIX = "DOTENV_PRIVATE_KEY"; -describe("legacyViperEnvBool", () => { - afterEach(() => { - delete process.env[KEY]; - }); - - it("is true only for strconv.ParseBool's true set (viper.GetBool parity)", () => { - for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { - process.env[KEY] = value; - expect(legacyViperEnvBool(KEY)).toBe(true); - } - }); +const withEnv = (env: Record<string, string>) => + makeLegacyViperEnvLayer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })); - it("is false for the false set and any unrecognized value", () => { - // viper casts via strconv.ParseBool and swallows the error to `false`, so - // `yes`/`on`/`""`/garbage are NOT truthy (unlike some bool parsers). - for (const value of ["0", "f", "F", "FALSE", "false", "False", "yes", "on", "", "nope"]) { - process.env[KEY] = value; - expect(legacyViperEnvBool(KEY)).toBe(false); - } - }); +describe("legacyViperEnvBool", () => { + it.live("is true only for strconv.ParseBool's true set (viper.GetBool parity)", () => + Effect.gen(function* () { + for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { + expect(yield* legacyViperEnvBool(KEY).pipe(Effect.provide(withEnv({ [KEY]: value })))).toBe( + true, + ); + } + }), + ); + + it.live("is false for the false set and any unrecognized value", () => + Effect.gen(function* () { + for (const value of ["0", "f", "F", "FALSE", "false", "False", "yes", "on", "", "nope"]) { + expect(yield* legacyViperEnvBool(KEY).pipe(Effect.provide(withEnv({ [KEY]: value })))).toBe( + false, + ); + } + }), + ); + + it.live("is false when the env var is absent", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvBool(KEY)).toBe(false); + }).pipe(Effect.provide(withEnv({}))), + ); +}); - it("is false when the env var is absent", () => { - delete process.env[KEY]; - expect(legacyViperEnvBool(KEY)).toBe(false); - }); +describe("legacyViperEnvEntries", () => { + it.live("returns every shell entry with the requested prefix", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvEntries(PRIVATE_KEY_PREFIX)).toEqual({ + DOTENV_PRIVATE_KEY: "base", + DOTENV_PRIVATE_KEY_PRODUCTION: "production", + DOTENV_PRIVATE_KEY_STAGING: "staging", + }); + }).pipe( + Effect.provide( + withEnv({ + DOTENV_PRIVATE_KEY: "base", + DOTENV_PRIVATE_KEY_PRODUCTION: "production", + DOTENV_PRIVATE_KEY_STAGING: "staging", + DOTENV_PRIVATE_KEYX: "not-a-match", + OTHER: "ignored", + }), + ), + ), + ); }); describe("legacyViperEnvBoolWithProjectFallback", () => { - afterEach(() => { - delete process.env[KEY]; - }); - - // Go truth table: godotenv.Load only sets project-.env keys ABSENT from the - // shell env (presence is key-existence, so even an empty shell value blocks - // the file value), then viper.GetBool reads the merged env - // (godotenv@v1.5.1/godotenv.go:184-200, apps/cli-go/pkg/config/config.go). - - it("falls back to the project value only when the shell var is absent", () => { - delete process.env[KEY]; - expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(true); - expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(false); - expect(legacyViperEnvBoolWithProjectFallback(KEY, {})).toBe(false); - }); - - it("keeps a false shell override even when the project .env says true", () => { - process.env[KEY] = "false"; - expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); - }); - - it("treats an empty shell value as present (blocks the project value) and false", () => { - // godotenv's presence check is key-existence in os.Environ(), and viper - // without AllowEmptyEnv resolves "" to the false default. - process.env[KEY] = ""; - expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); - }); - - it("treats an unparsable shell value as present and false (cast.ToBool swallows the error)", () => { - process.env[KEY] = "banana"; - expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); - }); - - it("keeps a true shell value over a false project value", () => { - process.env[KEY] = "true"; - expect(legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); - }); + it.live("falls back to the project value only when the shell var is absent", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(true); + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(false); + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, {})).toBe(false); + }).pipe(Effect.provide(withEnv({}))), + ); + + it.live("keeps a false shell override even when the project .env says true", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }).pipe(Effect.provide(withEnv({ [KEY]: "false" }))), + ); + + it.live("treats an empty shell value as present and false", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }).pipe(Effect.provide(withEnv({ [KEY]: "" }))), + ); + + it.live("treats an unparsable shell value as present and false", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }).pipe(Effect.provide(withEnv({ [KEY]: "banana" }))), + ); + + it.live("keeps a true shell value over a false project value", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); + }).pipe(Effect.provide(withEnv({ [KEY]: "true" }))), + ); }); describe("legacyViperEnvStringWithProjectFallback", () => { - afterEach(() => { - delete process.env[STRING_KEY]; - }); - - it("falls back to the project value only when the shell var is absent", () => { - delete process.env[STRING_KEY]; - expect( - legacyViperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), - ).toBe("project-value"); - expect(legacyViperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); - }); - - it("keeps the shell value over a project value", () => { - process.env[STRING_KEY] = "shell-value"; - expect( - legacyViperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), - ).toBe("shell-value"); - }); - - it("treats an empty shell value as present (blocks the project value)", () => { - // Same presence-based semantics as legacyViperEnvBoolWithProjectFallback: godotenv.Load's - // "don't override a key already in os.Environ()" check is key-existence, not value-truthiness. - process.env[STRING_KEY] = ""; - expect( - legacyViperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), - ).toBe(""); - }); - - it("returns an empty string (not undefined) when absent from both, matching viper.GetString", () => { - delete process.env[STRING_KEY]; - expect(legacyViperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); - }); + it.live("falls back to the project value only when the shell var is absent", () => + Effect.gen(function* () { + expect( + yield* legacyViperEnvStringWithProjectFallback(STRING_KEY, { + [STRING_KEY]: "project-value", + }), + ).toBe("project-value"); + expect(yield* legacyViperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); + }).pipe(Effect.provide(withEnv({}))), + ); + + it.live("keeps the shell value over a project value", () => + Effect.gen(function* () { + expect( + yield* legacyViperEnvStringWithProjectFallback(STRING_KEY, { + [STRING_KEY]: "project-value", + }), + ).toBe("shell-value"); + }).pipe(Effect.provide(withEnv({ [STRING_KEY]: "shell-value" }))), + ); + + it.live("treats an empty shell value as present and blocks the project value", () => + Effect.gen(function* () { + expect( + yield* legacyViperEnvStringWithProjectFallback(STRING_KEY, { + [STRING_KEY]: "project-value", + }), + ).toBe(""); + }).pipe(Effect.provide(withEnv({ [STRING_KEY]: "" }))), + ); + + it.live("returns an empty string when absent from both", () => + Effect.gen(function* () { + expect(yield* legacyViperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); + }).pipe(Effect.provide(withEnv({}))), + ); }); diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index 29e7ec90c8..afd26dd7b0 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -37,7 +37,10 @@ type FailCall = { suggestion?: string; }; -function mockOutput(format: "text" | "json" | "stream-json" = "text") { +function mockOutput(format: "text" | "json" | "stream-json" = "text"): { + readonly layer: Layer.Layer<Output>; + readonly failCalls: ReadonlyArray<FailCall>; +} { const failCalls: FailCall[] = []; return { layer: Layer.succeed(Output, { @@ -92,19 +95,16 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { describe("withJsonErrorHandling", () => { describe("text format", () => { it.live("re-raises the original error in text format", () => { + const out = mockOutput("text"); const processControl = mockProcessControl(); return Effect.gen(function* () { - const out = mockOutput("text"); const error = new TaggedErrorWithDetail({ message: "something went wrong", detail: "some detail", suggestion: "try again", }); - const failingEffect = Effect.fail(error); - const exit = yield* withJsonErrorHandling(failingEffect).pipe( - Effect.exit, - Effect.provide(out.layer), - ); + const failingEffect: Effect.Effect<never, TaggedErrorWithDetail> = Effect.fail(error); + const exit = yield* withJsonErrorHandling(failingEffect).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); const errorOption = Exit.findErrorOption(exit); expect(Option.isSome(errorOption)).toBe(true); @@ -112,7 +112,7 @@ describe("withJsonErrorHandling", () => { expect(errorOption.value).toBe(error); } expect(out.failCalls).toHaveLength(0); - }).pipe(Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, processControl.layer))); }); }); @@ -127,7 +127,7 @@ describe("withJsonErrorHandling", () => { suggestion: "try again", }); const failingEffect = Effect.fail(error); - yield* withJsonErrorHandling(failingEffect).pipe(Effect.provide(out.layer)); + yield* withJsonErrorHandling(failingEffect); expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]).toEqual({ code: "TaggedErrorWithDetail", @@ -136,7 +136,7 @@ describe("withJsonErrorHandling", () => { suggestion: "try again", }); expect(processControl.exitCode).toBe(1); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, processControl.layer))); }); it.live("includes detail and suggestion when present on error", () => { @@ -148,12 +148,12 @@ describe("withJsonErrorHandling", () => { detail: "in-depth explanation", suggestion: "do this instead", }); - yield* withJsonErrorHandling(Effect.fail(error)).pipe(Effect.provide(out.layer)); + yield* withJsonErrorHandling(Effect.fail(error)); expect(out.failCalls[0]).toMatchObject({ detail: "in-depth explanation", suggestion: "do this instead", }); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, processControl.layer))); }); it.live("omits detail and suggestion when absent on error", () => { @@ -161,14 +161,14 @@ describe("withJsonErrorHandling", () => { const processControl = mockProcessControl(); return Effect.gen(function* () { const error = new TaggedErrorMinimal({ message: "minimal error" }); - yield* withJsonErrorHandling(Effect.fail(error)).pipe(Effect.provide(out.layer)); + yield* withJsonErrorHandling(Effect.fail(error)); expect(out.failCalls).toHaveLength(1); const call = out.failCalls[0]!; expect(call.code).toBe("TaggedErrorMinimal"); expect(call.message).toBe("minimal error"); expect("detail" in call).toBe(false); expect("suggestion" in call).toBe(false); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, processControl.layer))); }); it.live("uses UnknownError code when error has no _tag", () => { @@ -176,11 +176,11 @@ describe("withJsonErrorHandling", () => { const processControl = mockProcessControl(); return Effect.gen(function* () { const error = new PlainError("plain error message"); - yield* withJsonErrorHandling(Effect.fail(error)).pipe(Effect.provide(out.layer)); + yield* withJsonErrorHandling(Effect.fail(error)); expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]?.code).toBe("UnknownError"); expect(out.failCalls[0]?.message).toBe("plain error message"); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, processControl.layer))); }); // CLI-1879: a delegated Go child's exact exit code must reach the user under @@ -195,11 +195,11 @@ describe("withJsonErrorHandling", () => { exitCode: 130, message: "supabase-go exited with code 130 (see stderr for details)", }); - yield* withJsonErrorHandling(Effect.fail(error)).pipe(Effect.provide(out.layer)); + yield* withJsonErrorHandling(Effect.fail(error)); expect(out.failCalls).toHaveLength(1); expect(out.failCalls[0]?.code).toBe("LegacyGoChildExitError"); expect(processControl.exitCode).toBe(130); - }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); + }).pipe(Effect.provide(Layer.mergeAll(out.layer, processControl.layer))); }); }); }); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index bf2a987284..ccc2a5b10b 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -14,7 +14,7 @@ import { text, } from "@clack/prompts"; import { styleText } from "node:util"; -import { Effect, Layer, Option, Stdio, Stream } from "effect"; +import { DateTime, Duration, Effect, Fiber, Layer, Option, Schema, Stdio, Stream } from "effect"; import { Tty } from "../runtime/tty.service.ts"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; @@ -23,6 +23,7 @@ import { Output } from "./output.service.ts"; import type { OutputFormat, StreamEvent } from "./types.ts"; const TASK_SPINNER_DELAY_MS = 200; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); // Reads the opt-in `MachineErrorContext` cell, if any command in this run // provided it — see that service's doc comment for the envelope contract. @@ -211,37 +212,37 @@ export const textOutputLayer = Layer.effect( event: (event: StreamEvent) => event.type === "log-entry" ? Effect.sync(() => log.info(`[${event.service}] ${event.line}`)) - : Effect.sync(() => log.info(JSON.stringify(event))), + : Effect.sync(() => log.info(encodeJson(event))), task: (message: string) => - Effect.sync(() => { + Effect.gen(function* () { let shown = false; let settled = false; let currentMessage = message; let task: ReturnType<typeof spinner> | undefined; - let timeout: ReturnType<typeof setTimeout> | undefined; - const cancelPendingStart = () => { - if (timeout !== undefined) { - clearTimeout(timeout); - timeout = undefined; - } - }; - - const finish = (render: () => void) => { - settled = true; - cancelPendingStart(); - render(); - }; + const startFiber = yield* Effect.sleep(Duration.millis(TASK_SPINNER_DELAY_MS)).pipe( + Effect.andThen( + Effect.sync(() => { + if (settled) { + return; + } + task = spinner(); + shown = true; + task.start(currentMessage); + }), + ), + Effect.forkChild, + ); - timeout = setTimeout(() => { - if (settled) { - return; - } - task = spinner(); - shown = true; - task.start(currentMessage); - timeout = undefined; - }, TASK_SPINNER_DELAY_MS); + const finish = (render: () => void) => + Effect.gen(function* () { + if (settled) { + return; + } + settled = true; + yield* Fiber.interrupt(startFiber); + yield* Effect.sync(render); + }); return { message: (nextMessage: string) => @@ -255,60 +256,50 @@ export const textOutputLayer = Layer.effect( } }), succeed: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.stop(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - log.success(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + task?.stop(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + log.success(nextMessage); + } + }), fail: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.error(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - log.error(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + task?.error(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + log.error(nextMessage); + } + }), info: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.clear(); - } - if (nextMessage !== undefined) { - log.info(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + task?.clear(); + } + if (nextMessage !== undefined) { + log.info(nextMessage); + } + }), cancel: (nextMessage?: string) => - Effect.sync(() => - finish(() => { - if (shown) { - task?.cancel(formatTaskMessage(nextMessage)); - return; - } - if (nextMessage !== undefined) { - cancel(nextMessage); - } - }), - ), + finish(() => { + if (shown) { + task?.cancel(formatTaskMessage(nextMessage)); + return; + } + if (nextMessage !== undefined) { + cancel(nextMessage); + } + }), clear: () => - Effect.sync(() => - finish(() => { - if (shown) { - task?.clear(); - } - }), - ), + finish(() => { + if (shown) { + task?.clear(); + } + }), }; }), promptText: ( @@ -423,7 +414,7 @@ export const jsonOutputLayer = Layer.effect( info: (message: string) => writeStderr(`${message}\n`), warn: (message: string) => writeStderr(`${message}\n`), error: (message: string) => writeStderr(`${message}\n`), - event: (event: StreamEvent) => writeStderr(`${JSON.stringify(event)}\n`), + event: (event: StreamEvent) => writeStderr(`${encodeJson(event)}\n`), task: (message: string) => Effect.sync(() => ({ message: (nextMessage: string) => writeStderr(`[task] ${nextMessage}\n`), @@ -456,7 +447,7 @@ export const jsonOutputLayer = Layer.effect( }; }), success: (message: string, data?: Record<string, unknown>) => - writeStdout(JSON.stringify({ ...data, message }) + "\n"), + writeStdout(encodeJson({ ...data, message }) + "\n"), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); @@ -464,7 +455,7 @@ export const jsonOutputLayer = Layer.effect( // never be clobbered by a context field of the same name (PR #6168 // review) — this is opt-in, command-contributed data; the envelope // shape it's decorating always wins. - yield* writeStdout(JSON.stringify({ ...extra, _tag: "Error", error: err }) + "\n"); + yield* writeStdout(encodeJson({ ...extra, _tag: "Error", error: err }) + "\n"); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), @@ -479,13 +470,15 @@ export const streamJsonOutputLayer = Layer.effect( const write = stdioWriter(yield* Stdio.Stdio); const writeStdout = (s: string) => write(s, "stdout"); const emitLog = (level: "info" | "warn" | "success" | "error", message: string) => { - const event: StreamEvent = { - type: "log", - level, - message, - timestamp: new Date().toISOString(), - }; - return writeStdout(JSON.stringify(event) + "\n"); + return Effect.gen(function* () { + const event: StreamEvent = { + type: "log", + level, + message, + timestamp: DateTime.formatIso(yield* DateTime.now), + }; + yield* writeStdout(encodeJson(event) + "\n"); + }); }; const nonInteractive = (action: string) => @@ -504,7 +497,7 @@ export const streamJsonOutputLayer = Layer.effect( info: (message: string) => emitLog("info", message), warn: (message: string) => emitLog("warn", message), error: (message: string) => emitLog("error", message), - event: (event: StreamEvent) => writeStdout(JSON.stringify(event) + "\n"), + event: (event: StreamEvent) => writeStdout(encodeJson(event) + "\n"), task: (message: string) => Effect.sync(() => ({ message: (nextMessage: string) => emitLog("info", nextMessage), @@ -523,15 +516,17 @@ export const streamJsonOutputLayer = Layer.effect( Effect.sync(() => { let current = 0; const emit = (status: "start" | "active" | "done", message: string) => { - const event: StreamEvent = { - type: "progress", - status, - current, - max: opts.max, - message, - timestamp: new Date().toISOString(), - }; - return writeStdout(JSON.stringify(event) + "\n"); + return Effect.gen(function* () { + const event: StreamEvent = { + type: "progress", + status, + current, + max: opts.max, + message, + timestamp: DateTime.formatIso(yield* DateTime.now), + }; + yield* writeStdout(encodeJson(event) + "\n"); + }); }; return { @@ -545,25 +540,27 @@ export const streamJsonOutputLayer = Layer.effect( }; }), success: (message: string, data?: Record<string, unknown>) => - writeStdout( - JSON.stringify({ - type: "result", - data: { ...data, message }, - timestamp: new Date().toISOString(), - }) + "\n", - ), + Effect.gen(function* () { + yield* writeStdout( + encodeJson({ + type: "result", + data: { ...data, message }, + timestamp: DateTime.formatIso(yield* DateTime.now), + }) + "\n", + ); + }), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); const event: StreamEvent = { type: "error", error: err, - timestamp: new Date().toISOString(), + timestamp: DateTime.formatIso(yield* DateTime.now), }; // `extra` spreads FIRST — same reasoning as the json layer's `fail` // above: the event's own `type`/`error`/`timestamp` must always win // over an opt-in context field of the same name (PR #6168 review). - yield* writeStdout(JSON.stringify({ ...extra, ...event }) + "\n"); + yield* writeStdout(encodeJson({ ...extra, ...event }) + "\n"); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), rawBytes: (bytes: Uint8Array, stream: "stdout" | "stderr" = "stdout") => write(bytes, stream), diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index e8df6def3c..e0fd79ecdb 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { afterEach, beforeEach, vi } from "vitest"; -import { Cause, Effect, Exit, Layer, Sink, Stdio, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Schema, Sink, Stdio, Stream } from "effect"; +import * as TestClock from "effect/testing/TestClock"; import { CONTEXT_CANCELED_MESSAGE, NonInteractiveError } from "./errors.ts"; import { mockTty } from "../../../tests/helpers/mocks.ts"; import { machineErrorContextLayer } from "./machine-error-context.layer.ts"; @@ -104,6 +105,10 @@ function getFailError(exit: Exit.Exit<unknown, unknown>): unknown { return fail.error; } +const decodeJsonObject = Schema.decodeSync( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), +); + describe("Output", () => { describe("text layer", () => { const layer = textOutputLayer.pipe( @@ -112,11 +117,10 @@ describe("Output", () => { it.effect("task uses clack spinner and can resolve into info", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.message("Still loading..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust("200 millis"); yield* task.info("Loaded organizations."); expect(mockClack.spinnerFactory).toHaveBeenCalledTimes(1); @@ -129,11 +133,10 @@ describe("Output", () => { it.effect("task skips the spinner when it completes quickly", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.succeed("Loaded organizations."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust("200 millis"); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.spinnerHandle.start).not.toHaveBeenCalled(); @@ -145,11 +148,10 @@ describe("Output", () => { "task keeps raw multiline formatting when it completes before the spinner shows", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); yield* task.succeed("- name: Supabase\n- name: Supabase Dev"); - vi.advanceTimersByTime(200); + yield* TestClock.adjust("200 millis"); expect(mockClack.spinnerFactory).not.toHaveBeenCalled(); expect(mockClack.log.success).toHaveBeenCalledWith( @@ -160,10 +162,9 @@ describe("Output", () => { it.effect("task prefixes continuation lines for multiline completions", () => Effect.gen(function* () { - vi.useFakeTimers(); const out = yield* Output; const task = yield* out.task("Loading organizations..."); - vi.advanceTimersByTime(200); + yield* TestClock.adjust("200 millis"); yield* task.succeed("- name: Supabase\n- name: Supabase Dev"); expect(mockClack.spinnerHandle.stop).toHaveBeenCalledWith( @@ -705,7 +706,7 @@ describe("Output", () => { const out = yield* Output; yield* out.success("ok", { id: 42 }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed).toEqual({ id: 42, message: "ok" }); }).pipe(Effect.provide(layer)); }); @@ -717,7 +718,7 @@ describe("Output", () => { const out = yield* Output; yield* out.fail({ code: "E_TEST", message: "failed", detail: "details" }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed", detail: "details" }, @@ -744,7 +745,7 @@ describe("Output", () => { yield* context.set({ linked_project: { project_ref: "abc" } }); yield* out.fail({ code: "E_TEST", message: "failed" }); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -760,7 +761,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_TEST", message: "failed" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -785,7 +786,7 @@ describe("Output", () => { const context = yield* MachineErrorContext; yield* context.set({ _tag: "Hacked", error: "Hacked", safe_field: "ok" }); yield* out.fail({ code: "E_TEST", message: "failed" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed).toEqual({ _tag: "Error", error: { code: "E_TEST", message: "failed" }, @@ -813,7 +814,7 @@ describe("Output", () => { const out = yield* Output; yield* out.intro("Starting up"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("Starting up"); @@ -828,7 +829,7 @@ describe("Output", () => { const out = yield* Output; yield* out.outro("All done"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("All done"); @@ -843,7 +844,7 @@ describe("Output", () => { const out = yield* Output; yield* out.info("stream info"); expect(mock.stdout).toHaveLength(1); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("info"); expect(parsed.message).toBe("stream info"); @@ -857,7 +858,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.warn("stream warn"); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("warn"); expect(parsed.message).toBe("stream warn"); @@ -870,7 +871,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.error("stream error"); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("log"); expect(parsed.level).toBe("error"); expect(parsed.message).toBe("stream error"); @@ -890,7 +891,7 @@ describe("Output", () => { line: "checkpoint complete", source: "live", }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed).toEqual({ type: "log-entry", timestamp: "2026-03-11T00:00:00.000Z", @@ -911,8 +912,8 @@ describe("Output", () => { yield* task.succeed("Loaded organizations."); expect(mock.stdout).toHaveLength(2); - const started = JSON.parse(mock.stdout[0]!); - const finished = JSON.parse(mock.stdout[1]!); + const started = decodeJsonObject(mock.stdout[0]!); + const finished = decodeJsonObject(mock.stdout[1]!); expect(started).toEqual( expect.objectContaining({ type: "log", @@ -990,7 +991,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.success("done", { key: "value" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("result"); expect(parsed.data).toEqual({ key: "value", message: "done" }); expect(parsed.timestamp).toBeDefined(); @@ -1003,7 +1004,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_FAIL", message: "boom", suggestion: "try again" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", @@ -1028,7 +1029,7 @@ describe("Output", () => { const context = yield* MachineErrorContext; yield* context.set({ linked_project: { project_ref: "abc" } }); yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", message: "boom" }); expect(parsed.linked_project).toEqual({ project_ref: "abc" }); @@ -1047,7 +1048,7 @@ describe("Output", () => { return Effect.gen(function* () { const out = yield* Output; yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(Object.keys(parsed).sort()).toEqual(["error", "timestamp", "type"]); }).pipe(Effect.provide(layer)); }); @@ -1073,7 +1074,7 @@ describe("Output", () => { safe_field: "ok", }); yield* out.fail({ code: "E_FAIL", message: "boom" }); - const parsed = JSON.parse(mock.stdout[0]!); + const parsed = decodeJsonObject(mock.stdout[0]!); expect(parsed.type).toBe("error"); expect(parsed.error).toEqual({ code: "E_FAIL", message: "boom" }); expect(typeof parsed.timestamp).toBe("string"); diff --git a/apps/cli/src/shared/output/table.unit.test.ts b/apps/cli/src/shared/output/table.unit.test.ts index 389d2aa46e..5113828504 100644 --- a/apps/cli/src/shared/output/table.unit.test.ts +++ b/apps/cli/src/shared/output/table.unit.test.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { columnWidths, formatTableRow, outputTable } from "./table.ts"; @@ -32,36 +32,34 @@ describe("formatTableRow", () => { }); describe("outputTable", () => { - it("emits a header row then one info message per data item", async () => { + it.effect("emits a header row then one info message per data item", () => { const out = mockOutput(); - await Effect.runPromise( - outputTable(["ID", "NAME"], [{ id: "1", name: "main" }], (r) => [r.id, r.name]).pipe( - Effect.provide(out.layer), - ), - ); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos).toEqual(["ID NAME", "1 main"]); + return Effect.gen(function* () { + yield* outputTable(["ID", "NAME"], [{ id: "1", name: "main" }], (r) => [r.id, r.name]); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos).toEqual(["ID NAME", "1 main"]); + }).pipe(Effect.provide(out.layer)); }); - it("uses header width when wider than any cell", async () => { + it.effect("uses header width when wider than any cell", () => { const out = mockOutput(); - await Effect.runPromise( - outputTable(["STATUS"], [{ s: "OK" }], (r) => [r.s]).pipe(Effect.provide(out.layer)), - ); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos[0]).toBe("STATUS"); - expect(infos[1]).toBe("OK "); + return Effect.gen(function* () { + yield* outputTable(["STATUS"], [{ s: "OK" }], (r) => [r.s]); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos[0]).toBe("STATUS"); + expect(infos[1]).toBe("OK "); + }).pipe(Effect.provide(out.layer)); }); - it("calls formatRow with cells, widths, and original item to produce row string", async () => { + it.effect("calls formatRow with cells, widths, and original item to produce row string", () => { const out = mockOutput(); const captured: Array<{ cells: ReadonlyArray<string>; widths: ReadonlyArray<number>; item: { name: string }; }> = []; - await Effect.runPromise( - outputTable( + return Effect.gen(function* () { + yield* outputTable( ["NAME"], [{ name: "alice" }], (r) => [r.name], @@ -69,13 +67,13 @@ describe("outputTable", () => { captured.push({ cells, widths, item }); return formatTableRow(cells, widths) + " [custom]"; }, - ).pipe(Effect.provide(out.layer)), - ); - expect(captured).toHaveLength(1); - expect(captured[0]!.cells).toEqual(["alice"]); - expect(captured[0]!.widths).toEqual([5]); - expect(captured[0]!.item).toEqual({ name: "alice" }); - const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); - expect(infos[1]).toBe("alice [custom]"); + ); + expect(captured).toHaveLength(1); + expect(captured[0]!.cells).toEqual(["alice"]); + expect(captured[0]!.widths).toEqual([5]); + expect(captured[0]!.item).toEqual({ name: "alice" }); + const infos = out.messages.filter((m) => m.type === "info").map((m) => m.message); + expect(infos[1]).toBe("alice [custom]"); + }).pipe(Effect.provide(out.layer)); }); }); diff --git a/apps/cli/src/shared/output/text-formatter.ts b/apps/cli/src/shared/output/text-formatter.ts index 4829507963..97f1793ade 100644 --- a/apps/cli/src/shared/output/text-formatter.ts +++ b/apps/cli/src/shared/output/text-formatter.ts @@ -16,7 +16,9 @@ export function textCliOutputFormatter(context?: CliErrorSuggestionContext): Cli error.changed ? error.message : stripSingleErrorHeader(base.formatErrors([error.source])); return { - ...base, + formatHelpDoc: base.formatHelpDoc, + formatCliError: base.formatCliError, + formatError: base.formatError, formatErrors: (errors) => { const formatted = formatCliErrorsForDisplay(errors, context); if (!formatted.changed) return base.formatErrors(errors); diff --git a/apps/cli/src/shared/runtime/browser.layer.unit.test.ts b/apps/cli/src/shared/runtime/browser.layer.unit.test.ts index a782f5a1ef..77ee1d0405 100644 --- a/apps/cli/src/shared/runtime/browser.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/browser.layer.unit.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { ConfigProvider, Effect, Layer, Sink, Stream } from "effect"; +import { PlatformError, SystemError } from "effect/PlatformError"; import { FileSystem } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; import { Browser } from "./browser.service.ts"; import { browserLayer } from "./browser.layer.ts"; @@ -12,21 +13,23 @@ function mockSpawner() { const spawned: SpawnedCommand[] = []; const layer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command: any) => - Effect.sync(() => { - const cmd = command as { _tag: string; command: string; args: readonly string[] }; - spawned.push({ command: cmd.command, args: cmd.args }); + ChildProcessSpawner.make((command: ChildProcess.Command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") { + return yield* Effect.die("browser test received a piped command"); + } + spawned.push({ command: command.command, args: command.args }); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), - stdin: Sink.drain as any, + stdin: Sink.drain, stdout: Stream.empty, stderr: Stream.empty, all: Stream.empty, - getInputFd: () => Sink.drain as any, + getInputFd: () => Sink.drain, getOutputFd: () => Stream.empty, }); }), @@ -142,7 +145,18 @@ describe("Browser", () => { it.effect("errors are ignored when spawner fails", () => { const failingLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.fail(new Error("spawn failed") as any)), + ChildProcessSpawner.make(() => + Effect.fail( + new PlatformError( + new SystemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), + ), + ), + ), ); const configLayer = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); const layer = Layer.mergeAll( diff --git a/apps/cli/src/shared/runtime/command-runtime.layer.ts b/apps/cli/src/shared/runtime/command-runtime.layer.ts index 021770dd2c..36dfced4f2 100644 --- a/apps/cli/src/shared/runtime/command-runtime.layer.ts +++ b/apps/cli/src/shared/runtime/command-runtime.layer.ts @@ -1,13 +1,16 @@ -import { Effect, Layer } from "effect"; +import { Crypto, Effect, Layer } from "effect"; import { CommandRuntime } from "./command-runtime.service.ts"; export const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => Layer.effect( CommandRuntime, - Effect.sync(() => - CommandRuntime.of({ + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + return CommandRuntime.of({ commandPath: [...commandPath], - commandRunId: crypto.randomUUID(), - }), - ), + // Correlation ID generation has no recoverable command-domain failure, + // matching the previous ambient crypto behavior at this boundary. + commandRunId: yield* crypto.randomUUIDv4.pipe(Effect.orDie), + }); + }), ); diff --git a/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts b/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts index cfbb024fa1..a1a084cc0e 100644 --- a/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/command-runtime.layer.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Layer } from "effect"; import { commandRuntimeLayer } from "./command-runtime.layer.ts"; import { @@ -9,14 +10,23 @@ import { } from "./command-runtime.service.ts"; describe("commandRuntimeLayer", () => { + const testLayer = (commandPath: ReadonlyArray<string>) => + commandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + + it.effect("generates UUID-shaped run ids with full entropy", () => + Effect.gen(function* () { + const runtime = yield* CommandRuntime; + + expect(runtime.commandRunId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }).pipe(Effect.provide(testLayer(["status"]))), + ); + it.effect("generates a fresh command run id for each invocation", () => Effect.gen(function* () { - const first = yield* Effect.gen(function* () { - return yield* CommandRuntime; - }).pipe(Effect.provide(commandRuntimeLayer(["status"]))); - const second = yield* Effect.gen(function* () { - return yield* CommandRuntime; - }).pipe(Effect.provide(commandRuntimeLayer(["status"]))); + const first = yield* CommandRuntime.pipe(Effect.provide(testLayer(["status"]))); + const second = yield* CommandRuntime.pipe(Effect.provide(testLayer(["status"]))); expect(first.commandPath).toEqual(["status"]); expect(second.commandPath).toEqual(["status"]); diff --git a/apps/cli/src/shared/runtime/config-environment.ts b/apps/cli/src/shared/runtime/config-environment.ts new file mode 100644 index 0000000000..83f10cefd3 --- /dev/null +++ b/apps/cli/src/shared/runtime/config-environment.ts @@ -0,0 +1,28 @@ +import { ConfigProvider, Effect, Match } from "effect"; + +/** Collects a configuration provider into the flat key/value shape used by an environment. */ +export const collectConfigEnvironment = ( + provider: ConfigProvider.ConfigProvider, +): Effect.Effect<Record<string, string>, ConfigProvider.SourceError> => + Effect.gen(function* () { + const environment: Record<string, string> = {}; + const visit = (path: ConfigProvider.Path): Effect.Effect<void, ConfigProvider.SourceError> => + Effect.gen(function* () { + const node = yield* provider.load(path); + if (node === undefined) return; + + const key = path.map(String).join("_"); + if (key.length > 0 && node.value !== undefined) { + environment[key] = node.value; + } + const children = Match.valueTags(node, { + Value: () => [], + Record: ({ keys }) => [...keys], + Array: ({ length }) => Array.from({ length }, (_, index) => index), + }); + yield* Effect.forEach(children, (child) => visit([...path, child]), { discard: true }); + }); + + yield* visit([]); + return environment; + }); diff --git a/apps/cli/src/shared/runtime/ink.layer.ts b/apps/cli/src/shared/runtime/ink.layer.ts index 3f9d790f10..93e015be0b 100644 --- a/apps/cli/src/shared/runtime/ink.layer.ts +++ b/apps/cli/src/shared/runtime/ink.layer.ts @@ -5,9 +5,12 @@ import { Ink } from "./ink.service.ts"; export const inkLayer = Layer.sync(Ink, () => Ink.of({ render: (element) => - Effect.promise(async () => { - const { render } = await import("ink"); - return render(element, { exitOnCtrlC: false }); - }), + Effect.tryPromise(() => import("ink")).pipe( + Effect.flatMap(({ render }) => Effect.sync(() => render(element, { exitOnCtrlC: false }))), + // Ink is a package dependency of the CLI; failure to load it is a + // deployment/programming defect rather than a recoverable command + // failure, so preserve the service's never-error contract. + Effect.orDie, + ), }), ); diff --git a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts index a2290f839a..cf2e2303a4 100644 --- a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts @@ -96,7 +96,7 @@ describe("ProcessControl", () => { Effect.gen(function* () { yield* processControl.holdSignals(["SIGINT", "SIGTERM"]); yield* Effect.sync(() => Deferred.doneUnsafe(ready, Effect.void)); - yield* Effect.never; + return yield* Effect.never; }), ).pipe(Effect.forkChild({ startImmediately: true })); diff --git a/apps/cli/src/shared/runtime/runtime-info.layer.ts b/apps/cli/src/shared/runtime/runtime-info.layer.ts index c4a7df5035..50d5d586ee 100644 --- a/apps/cli/src/shared/runtime/runtime-info.layer.ts +++ b/apps/cli/src/shared/runtime/runtime-info.layer.ts @@ -1,4 +1,4 @@ -import { homedir } from "node:os"; +import { homedir, userInfo } from "node:os"; import process from "node:process"; import { Layer } from "effect"; @@ -12,5 +12,12 @@ export const runtimeInfoLayer = Layer.sync(RuntimeInfo, () => homeDir: homedir(), execPath: process.execPath, pid: process.pid, + osUser: (() => { + try { + return userInfo().username || undefined; + } catch { + return undefined; + } + })(), }), ); diff --git a/apps/cli/src/shared/runtime/runtime-info.service.ts b/apps/cli/src/shared/runtime/runtime-info.service.ts index bba5a35854..75127931d2 100644 --- a/apps/cli/src/shared/runtime/runtime-info.service.ts +++ b/apps/cli/src/shared/runtime/runtime-info.service.ts @@ -1,12 +1,14 @@ import { Context } from "effect"; -interface RuntimeInfoShape { +export interface RuntimeInfoShape { readonly cwd: string; readonly platform: NodeJS.Platform; readonly arch: NodeJS.Architecture; readonly homeDir: string; readonly execPath: string; readonly pid: number; + /** The host OS account name, when the platform exposes it. */ + readonly osUser?: string; } export class RuntimeInfo extends Context.Service<RuntimeInfo, RuntimeInfoShape>()( diff --git a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts index a25d1f13bf..3bc04b84cb 100644 --- a/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts +++ b/apps/cli/src/shared/runtime/stack-e2e-cleanup.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- these tests inject Promise-based OS cleanup fakes at the foreign boundary. import { describe, expect, it, vi } from "@effect/vitest"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 56e5aa666d..00e6bf39cc 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -326,7 +326,7 @@ const fetchPostgrestVersion = Effect.fnUntraced(function* ( const normalized = version?.trim().split(/\s+/)[0]; if (normalized === undefined || normalized.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "postgrest" })); + return yield* new ServiceVersionNotFoundError({ service: "postgrest" }); } return normalized.startsWith("v") ? normalized : `v${normalized}`; @@ -341,7 +341,7 @@ const fetchAuthVersion = Effect.fnUntraced(function* ( const version = stringField(body, "version")?.trim(); if (version === undefined || version.length === 0) { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "auth" })); + return yield* new ServiceVersionNotFoundError({ service: "auth" }); } return version; @@ -354,7 +354,7 @@ const fetchStorageVersion = Effect.fnUntraced(function* ( ) { const version = (yield* fetchText(client, `${baseUrl}/storage/v1/version`, accessKey)).trim(); if (version.length === 0 || version === "0.0.0") { - return yield* Effect.fail(new ServiceVersionNotFoundError({ service: "storage" })); + return yield* new ServiceVersionNotFoundError({ service: "storage" }); } return version.startsWith("v") ? version : `v${version}`; @@ -362,7 +362,7 @@ const fetchStorageVersion = Effect.fnUntraced(function* ( const fetchOptionalVersion = ( service: OptionalRemoteServiceName, - effect: Effect.Effect<string, unknown>, + effect: Effect.Effect<string, Error>, ) => effect.pipe( Effect.exit, diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index bb343ee103..4a9fe2b7b4 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import serviceImagesDockerfile from "../../../../cli-go/pkg/config/templates/Dockerfile" with { type: "text" }; @@ -17,10 +17,24 @@ const PROJECT_REF = "abcdefghijklmnopqrst"; // `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead // of self-provisioning one, so each invocation needs a concrete transport. const runLinkedFetch = (input: Parameters<typeof fetchLinkedServiceVersions>[0]) => - Effect.runPromise(fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer))); + fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); + +const withServer = <A>( + fetch: (request: Request) => Response | Promise<Response>, + use: (origin: string) => Effect.Effect<A>, +) => + Effect.acquireUseRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch })), + (server) => use(server.url.origin), + (server) => + Effect.tryPromise({ + try: () => server.stop(true), + catch: () => undefined, + }).pipe(Effect.ignore), + ); describe("services shared", () => { - test("parses service images from Dockerfile FROM aliases", () => { + it("parses service images from Dockerfile FROM aliases", () => { expect( parseDockerfileServiceImages(` # comment @@ -35,13 +49,13 @@ describe("services shared", () => { ]); }); - test("fails clearly when the Dockerfile manifest misses a required service alias", () => { + it("fails clearly when the Dockerfile manifest misses a required service alias", () => { expect(() => localServiceImagesFromDockerfile("FROM supabase/postgres:17.6.1.132 AS pg\n"), ).toThrow("Missing service image alias 'gotrue' in Dockerfile manifest."); }); - test("derives local service versions from the Go Dockerfile manifest", () => { + it("derives local service versions from the Go Dockerfile manifest", () => { const rows = listLocalServiceVersions(); const dockerfileImages = localServiceImagesFromDockerfile(serviceImagesDockerfile); const expectedRows = dockerfileImages.map((service) => { @@ -68,7 +82,7 @@ describe("services shared", () => { ]); }); - test("can preserve raw local service version overrides", () => { + it("can preserve raw local service version overrides", () => { expect( listLocalServiceVersions({ normalizeVersionTags: false, @@ -84,10 +98,9 @@ describe("services shared", () => { ); }); - test("returns postgres only when no service-role key is available", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { + it.effect("returns postgres only when no service-role key is available", () => + withServer( + (request) => { const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}`) { return Response.json({ @@ -132,28 +145,25 @@ describe("services shared", () => { return new Response("not found", { status: 404 }); }, - }); - - try { - const result = await runLinkedFetch({ - apiUrl: server.url.origin, - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - tenantBaseUrlOverride: server.url.origin, - }); + (origin) => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: origin, + }); - expect(result).toEqual({ postgres: "17.6.1.200" }); - } finally { - await server.stop(true); - } - }); + expect(result).toEqual({ postgres: "17.6.1.200" }); + }), + ), + ); - test("returns no linked versions when project api keys cannot be loaded", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { + it.effect("returns no linked versions when project api keys cannot be loaded", () => + withServer( + (request) => { const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { return new Response("boom", { status: 500 }); @@ -180,28 +190,25 @@ describe("services shared", () => { return new Response("not found", { status: 404 }); }, - }); - - try { - const result = await runLinkedFetch({ - apiUrl: server.url.origin, - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - tenantBaseUrlOverride: server.url.origin, - }); + (origin) => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: origin, + }); - expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + expect(result).toEqual({}); + }), + ), + ); - test("still returns tenant service versions when project version lookup fails", async () => { - const server = Bun.serve({ - port: 0, - fetch(request) { + it.effect("still returns tenant service versions when project version lookup fails", () => + withServer( + (request) => { const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}`) { return new Response("boom", { status: 500 }); @@ -234,45 +241,44 @@ describe("services shared", () => { return new Response("not found", { status: 404 }); }, - }); + (origin) => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: origin, + }); - try { - const result = await runLinkedFetch({ - apiUrl: server.url.origin, + expect(result).toEqual({ + auth: "v2.190.0", + postgrest: "v14.13", + storage: "v1.61.0", + }); + }), + ), + ); + + it.effect("falls back to empty linked versions when the linked fetch fails", () => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: "http://127.0.0.1:1", projectHost: "supabase.co", projectRef: PROJECT_REF, accessToken: ACCESS_TOKEN, userAgent: "supabase", - tenantBaseUrlOverride: server.url.origin, - }); - - expect(result).toEqual({ - auth: "v2.190.0", - postgrest: "v14.13", - storage: "v1.61.0", }); - } finally { - await server.stop(true); - } - }); - - test("falls back to empty linked versions when the linked fetch fails", async () => { - const result = await runLinkedFetch({ - apiUrl: "http://127.0.0.1:1", - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - }); - expect(result).toEqual({}); - }); + expect(result).toEqual({}); + }), + ); - test("authenticates tenant probes with apikey only for sb_ keys", async () => { + it.effect("authenticates tenant probes with apikey only for sb_ keys", () => { const authHeaders: Record<string, string | null> = {}; - const server = Bun.serve({ - port: 0, - fetch(request) { + return withServer( + (request) => { const url = new URL(request.url); if (url.pathname === `/v1/projects/${PROJECT_REF}/api-keys`) { return Response.json([ @@ -303,50 +309,45 @@ describe("services shared", () => { return new Response("not found", { status: 404 }); }, - }); - - try { - const result = await runLinkedFetch({ - apiUrl: server.url.origin, - projectHost: "supabase.co", - projectRef: PROJECT_REF, - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - tenantBaseUrlOverride: server.url.origin, - }); + (origin) => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: origin, + projectHost: "supabase.co", + projectRef: PROJECT_REF, + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + tenantBaseUrlOverride: origin, + }); - expect(result).toEqual({ auth: "v2.190.0" }); - expect(authHeaders.apikey).toBe("sb_secret_servicerolekey"); - expect(authHeaders.authorization).toBeNull(); - } finally { - await server.stop(true); - } + expect(result).toEqual({ auth: "v2.190.0" }); + expect(authHeaders.apikey).toBe("sb_secret_servicerolekey"); + expect(authHeaders.authorization).toBeNull(); + }), + ); }); - test("skips remote lookups for a malformed project ref", async () => { - const server = Bun.serve({ - port: 0, - fetch() { + it.effect("skips remote lookups for a malformed project ref", () => + withServer( + () => { throw new Error("no request should be made for a malformed project ref"); }, - }); - - try { - const result = await runLinkedFetch({ - apiUrl: server.url.origin, - projectHost: "supabase.co", - projectRef: "not-a-valid-ref", - accessToken: ACCESS_TOKEN, - userAgent: "supabase", - }); + (origin) => + Effect.gen(function* () { + const result = yield* runLinkedFetch({ + apiUrl: origin, + projectHost: "supabase.co", + projectRef: "not-a-valid-ref", + accessToken: ACCESS_TOKEN, + userAgent: "supabase", + }); - expect(result).toEqual({}); - } finally { - await server.stop(true); - } - }); + expect(result).toEqual({}); + }), + ), + ); - test("renders the local services table with expected headers and rows", () => { + it("renders the local services table with expected headers and rows", () => { const rows = listLocalServiceVersions(); const table = renderServicesTable(rows); @@ -360,7 +361,7 @@ describe("services shared", () => { } }); - test("renders update warning only for mismatched linked versions", () => { + it("renders update warning only for mismatched linked versions", () => { expect( renderServicesWarning([ { name: "supabase/postgres", local: "17.6.1.132", remote: "17.6.1.200" }, diff --git a/apps/cli/src/shared/telemetry/ai-tool.layer.ts b/apps/cli/src/shared/telemetry/ai-tool.layer.ts index ba45285bb0..489fac37e5 100644 --- a/apps/cli/src/shared/telemetry/ai-tool.layer.ts +++ b/apps/cli/src/shared/telemetry/ai-tool.layer.ts @@ -1,4 +1,4 @@ -import { determineAgent } from "@vercel/detect-agent"; +import { determineAgent, type AgentResult } from "@vercel/detect-agent"; import { Effect, Layer, Option } from "effect"; import { AiTool } from "./ai-tool.service.ts"; @@ -6,20 +6,17 @@ function normalizeAgentName(name: string): string { return name.replace(/-/g, "_"); } -export const aiToolLayer = Layer.effect( - AiTool, - Effect.promise(() => determineAgent()).pipe( - Effect.map((result) => - AiTool.of({ - name: result.isAgent ? Option.some(normalizeAgentName(result.agent.name)) : Option.none(), - }), - ), - Effect.catch(() => - Effect.succeed( +export const makeAiToolLayer = (detect: () => Promise<AgentResult> = determineAgent) => + Layer.effect( + AiTool, + Effect.promise(detect).pipe( + Effect.map((result) => AiTool.of({ - name: Option.none(), + name: result.isAgent ? Option.some(normalizeAgentName(result.agent.name)) : Option.none(), }), ), + Effect.orElseSucceed(() => AiTool.of({ name: Option.none() })), ), - ), -); + ); + +export const aiToolLayer = makeAiToolLayer(); diff --git a/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts b/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts index 8576e6e19c..fdbde43de7 100644 --- a/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/ai-tool.layer.unit.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Option } from "effect"; -import { processEnvLayer } from "../../../tests/helpers/mocks.ts"; -import { aiToolLayer } from "./ai-tool.layer.ts"; +import { makeAiToolLayer } from "./ai-tool.layer.ts"; import { AiTool } from "./ai-tool.service.ts"; describe("aiToolLayer", () => { @@ -9,7 +8,11 @@ describe("aiToolLayer", () => { Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("codex")); - }).pipe(Effect.provide(aiToolLayer), Effect.provide(processEnvLayer({ CODEX_SANDBOX: "1" }))), + }).pipe( + Effect.provide( + makeAiToolLayer(() => Promise.resolve({ isAgent: true, agent: { name: "codex" } })), + ), + ), ); it.live("normalizes known agent names for analytics properties", () => @@ -17,8 +20,11 @@ describe("aiToolLayer", () => { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("github_copilot")); }).pipe( - Effect.provide(aiToolLayer), - Effect.provide(processEnvLayer({ AI_AGENT: "github-copilot-cli" })), + Effect.provide( + makeAiToolLayer(() => + Promise.resolve({ isAgent: true, agent: { name: "github-copilot" } }), + ), + ), ), ); @@ -26,6 +32,8 @@ describe("aiToolLayer", () => { Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.none()); - }).pipe(Effect.provide(aiToolLayer), Effect.provide(processEnvLayer({}))), + }).pipe( + Effect.provide(makeAiToolLayer(() => Promise.resolve({ isAgent: false, agent: undefined }))), + ), ); }); diff --git a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts index ed81a5fb20..faad62f8a7 100644 --- a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts +++ b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts @@ -8,10 +8,7 @@ describe("withAnalyticsContext", () => { const before = yield* CurrentAnalyticsContext; expect(before).toEqual({}); - const nested = yield* Effect.gen(function* () { - const current = yield* CurrentAnalyticsContext; - return current; - }).pipe( + const nested = yield* Effect.service(CurrentAnalyticsContext).pipe( withAnalyticsContext({ command_run_id: "run-123", groups: { @@ -43,11 +40,7 @@ describe("withAnalyticsContext", () => { it.live("is inherited by child fibers", () => Effect.gen(function* () { const child = yield* Effect.gen(function* () { - const fiber = yield* Effect.forkChild( - Effect.gen(function* () { - return yield* CurrentAnalyticsContext; - }), - ); + const fiber = yield* Effect.forkChild(Effect.service(CurrentAnalyticsContext)); return yield* Fiber.join(fiber); }).pipe( withAnalyticsContext({ diff --git a/apps/cli/src/shared/telemetry/analytics.layer.ts b/apps/cli/src/shared/telemetry/analytics.layer.ts index e25f983d7f..95aa135367 100644 --- a/apps/cli/src/shared/telemetry/analytics.layer.ts +++ b/apps/cli/src/shared/telemetry/analytics.layer.ts @@ -89,7 +89,7 @@ export const analyticsLayer = Layer.effect( onNone: () => Effect.succeed(Option.none<ProjectLinkStateValue>()), onSome: (projectLinkState) => projectLinkState.load.pipe( - Effect.catch(() => Effect.succeed(Option.none<ProjectLinkStateValue>())), + Effect.orElseSucceed(() => Option.none<ProjectLinkStateValue>()), ), }); const groups = resolveGroups(context, linkedProject); diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.ts b/apps/cli/src/shared/telemetry/command-instrumentation.ts index 3cb3b4477e..665e189731 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.ts @@ -42,7 +42,7 @@ function extractFlagsUsed(args: ReadonlyArray<string>): ReadonlyArray<string> { return [...used].sort((left, right) => left.localeCompare(right)); } -function normalizeFlagValue(value: unknown): unknown | undefined { +function normalizeFlagValue(value: unknown): unknown { if (value === undefined) return undefined; if (!Option.isOption(value)) return value; if (Option.isNone(value)) return undefined; diff --git a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts index 10cb1dd66f..4d2230c389 100644 --- a/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts +++ b/apps/cli/src/shared/telemetry/command-instrumentation.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Data, Effect, Exit, Layer, Option, Stdio } from "effect"; -import { commandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Data, Effect, Exit, Layer, Option, Schema, Stdio } from "effect"; +import { commandRuntimeLayer as rawCommandRuntimeLayer } from "../runtime/command-runtime.layer.ts"; import { CurrentAnalyticsContext } from "./analytics-context.ts"; import { Analytics } from "./analytics.service.ts"; import { withCommandInstrumentation } from "./command-instrumentation.ts"; @@ -20,6 +21,9 @@ import { } from "./event-catalog.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +const commandRuntimeLayer = (commandPath: ReadonlyArray<string>) => + rawCommandRuntimeLayer(commandPath).pipe(Layer.provide(BunServices.layer)); + const FAILURE_PROPERTY_NAMES = [ PropErrorKind, PropErrorCategory, @@ -107,14 +111,16 @@ describe("withCommandInstrumentation", () => { expect(typeof span.attributes.get("command_run_id")).toBe("string"); }).pipe( withCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["branches", "list"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["branches", "list"]), + }), + commandRuntimeLayer(["branches", "list"]), + ), ), - Effect.provide(commandRuntimeLayer(["branches", "list"])), ); }); @@ -130,14 +136,16 @@ describe("withCommandInstrumentation", () => { }); }).pipe( withCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["start", "--detach", "--exclude=auth"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["start", "--detach", "--exclude=auth"]), + }), + commandRuntimeLayer(["start"]), + ), ), - Effect.provide(commandRuntimeLayer(["start"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(2); @@ -174,37 +182,43 @@ describe("withCommandInstrumentation", () => { const failure = new InstrumentationAuthError(secrets); const program = withCommandInstrumentation()(Effect.fail(failure)).pipe( - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["login"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["login"]), + }), + commandRuntimeLayer(["login"]), + ), ), - Effect.provide(commandRuntimeLayer(["login"])), Effect.exit, Effect.tap((exit) => - Effect.sync(() => { + Effect.gen(function* () { expect(analytics.captured).toHaveLength(1); const event = analytics.captured[0]; - expect(event?.event).toBe("cli_command_executed"); - expect(event?.properties).toMatchObject({ - exit_code: 1, - error_kind: "user_actionable", - error_category: "auth", - error_fingerprint: "tag:InstrumentationAuthError", - has_suggestion: true, - suggestion_type: "login", - suggested_command: "supabase login", - }); - expect(event?.properties).not.toHaveProperty(PropWorkflow); - const encoded = JSON.stringify(event); - for (const secret of Object.values(secrets)) expect(encoded).not.toContain(secret); + const encoded = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + event, + ); + yield* Effect.sync(() => { + expect(event?.event).toBe("cli_command_executed"); + expect(event?.properties).toMatchObject({ + exit_code: 1, + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:InstrumentationAuthError", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase login", + }); + expect(event?.properties).not.toHaveProperty(PropWorkflow); + for (const secret of Object.values(secrets)) expect(encoded).not.toContain(secret); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(failure); - } + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toBe(failure); + } + }); }), ), ); @@ -218,22 +232,31 @@ describe("withCommandInstrumentation", () => { return Effect.die(new TypeError(secret)).pipe( withCommandInstrumentation(), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) })), - Effect.provide(commandRuntimeLayer(["branches", "list"])), + Effect.provide( + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["branches", "list"]) }), + commandRuntimeLayer(["branches", "list"]), + ), + ), Effect.exit, Effect.tap(() => - Effect.sync(() => { - expect(analytics.captured[0]?.properties).toMatchObject({ - exit_code: 1, - error_kind: "internal_bug", - error_category: "panic", - error_fingerprint: "error:TypeError", - has_suggestion: true, - suggestion_type: "rerun_debug", + Effect.gen(function* () { + const encoded = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + analytics.captured[0], + ); + yield* Effect.sync(() => { + expect(analytics.captured[0]?.properties).toMatchObject({ + exit_code: 1, + error_kind: "internal_bug", + error_category: "panic", + error_fingerprint: "error:TypeError", + has_suggestion: true, + suggestion_type: "rerun_debug", + }); + expect(encoded).not.toContain(secret); }); - expect(JSON.stringify(analytics.captured[0])).not.toContain(secret); }), ), Effect.asVoid, @@ -252,10 +275,14 @@ describe("withCommandInstrumentation", () => { return Effect.fail(failure).pipe( withCommandInstrumentation(), - Effect.provide(failingAnalytics(new Error("telemetry defect"))), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), - Effect.provide(commandRuntimeLayer(["login"])), + Effect.provide( + Layer.mergeAll( + failingAnalytics(new Error("telemetry defect")), + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -276,10 +303,14 @@ describe("withCommandInstrumentation", () => { // is being cancelled and swallowing would fight the cancellation. return Effect.void.pipe( withCommandInstrumentation(), - Effect.provide(interruptingAnalytics()), - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(Stdio.layerTest({ args: Effect.succeed(["login"]) })), - Effect.provide(commandRuntimeLayer(["login"])), + Effect.provide( + Layer.mergeAll( + interruptingAnalytics(), + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ args: Effect.succeed(["login"]) }), + commandRuntimeLayer(["login"]), + ), + ), Effect.exit, Effect.tap((exit) => Effect.sync(() => { @@ -307,22 +338,24 @@ describe("withCommandInstrumentation", () => { }, allowedFlagValues: ["exclude", "mode", "stack"], }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed([ - "start", - "--detach", - "--mode=docker", - "--exclude", - "auth", - "--exclude", - "storage", - ]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed([ + "start", + "--detach", + "--mode=docker", + "--exclude", + "auth", + "--exclude", + "storage", + ]), + }), + commandRuntimeLayer(["start"]), + ), ), - Effect.provide(commandRuntimeLayer(["start"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -352,14 +385,16 @@ describe("withCommandInstrumentation", () => { }, allowedFlagValues: ["token", "name", "noBrowser"], }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["login", "--name", "my-machine", "--no-browser"]), + }), + commandRuntimeLayer(["login"]), + ), ), - Effect.provide(commandRuntimeLayer(["login"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -376,16 +411,18 @@ describe("withCommandInstrumentation", () => { it.live("skips analytics capture when analytics are disabled", () => { const analytics = mockContextualAnalytics(); - return Effect.sync(() => "ok").pipe( + return Effect.succeed("ok").pipe( withCommandInstrumentation({ analytics: false }), - Effect.provide(analytics.layer), - Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide( - Stdio.layerTest({ - args: Effect.succeed(["telemetry", "enable"]), - }), + Layer.mergeAll( + analytics.layer, + mockOutput({ format: "text" }).layer, + Stdio.layerTest({ + args: Effect.succeed(["telemetry", "enable"]), + }), + commandRuntimeLayer(["telemetry", "enable"]), + ), ), - Effect.provide(commandRuntimeLayer(["telemetry", "enable"])), Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toEqual([]); diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index eefe8b86af..8a9222b9b1 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -1,5 +1,11 @@ -import { Effect, FileSystem, Option, Path, Schema } from "effect"; +import { Crypto, Data, Effect, FileSystem, Option, Path, Schema } from "effect"; +import type * as PlatformError from "effect/PlatformError"; import { CliConfig } from "../../next/config/cli-config.service.ts"; +import { + actionability, + ErrorActionabilityId, + type CliErrorActionabilityDeclaration, +} from "./error-actionability.ts"; import { type ConsentState, TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; export const getConfigDir = CliConfig.useSync((cliConfig) => cliConfig.supabaseHome); @@ -11,7 +17,7 @@ const LegacyTelemetryConfigSchema = Schema.Struct({ session_id: Schema.String, session_last_active: Schema.String, distinct_id: Schema.optionalKey(Schema.String), - schema_version: Schema.optionalKey(Schema.Number), + schema_version: Schema.optionalKey(Schema.Finite), }); type LegacyTelemetryConfig = Schema.Schema.Type<typeof LegacyTelemetryConfigSchema>; @@ -43,36 +49,67 @@ function legacyConfigToTelemetryConfig( }; } -const decodeTelemetryConfigFile = Effect.fnUntraced(function* (content: string) { +export class TelemetryConfigError extends Data.TaggedError("TelemetryConfigError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +type TelemetryConfigEffect<A> = Effect.Effect< + A, + TelemetryConfigError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +>; + +const decodeTelemetryConfigFile: ( + content: string, +) => Effect.Effect<TelemetryConfig, TelemetryConfigError> = Effect.fnUntraced(function* ( + content: string, +) { return yield* decodeCurrentTelemetryConfigFile(content).pipe( + Effect.mapError( + (cause) => new TelemetryConfigError({ message: "invalid telemetry state", cause }), + ), Effect.catch(() => - Effect.gen(function* () { - const legacyConfig = yield* decodeLegacyTelemetryConfigFile(content); - const config = legacyConfigToTelemetryConfig(legacyConfig); - if (config === undefined) { - return yield* Effect.fail(new Error("invalid legacy telemetry state")); - } - return config; - }), + decodeLegacyTelemetryConfigFile(content).pipe( + Effect.mapError( + (cause) => new TelemetryConfigError({ message: "invalid legacy telemetry state", cause }), + ), + Effect.flatMap((legacyConfig) => { + const config = legacyConfigToTelemetryConfig(legacyConfig); + return config === undefined + ? Effect.fail(new TelemetryConfigError({ message: "invalid legacy telemetry state" })) + : Effect.succeed(config); + }), + ), ), ); }); -export const readTelemetryConfig = Effect.fnUntraced( - function* (configDir: string) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const configPath = path.join(configDir, "telemetry.json"); - const exists = yield* fs.exists(configPath); - if (!exists) return Option.none<TelemetryConfig>(); - const content = yield* fs.readFileString(configPath); - const config = yield* decodeTelemetryConfigFile(content); - return Option.some(config); - }, - (effect) => Effect.orElseSucceed(effect, () => Option.none<TelemetryConfig>()), -); +export const readTelemetryConfig: ( + configDir: string, +) => Effect.Effect<Option.Option<TelemetryConfig>, never, FileSystem.FileSystem | Path.Path> = + Effect.fnUntraced( + function* (configDir: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(configDir, "telemetry.json"); + const exists = yield* fs.exists(configPath); + if (!exists) return Option.none<TelemetryConfig>(); + const content = yield* fs.readFileString(configPath); + const config = yield* decodeTelemetryConfigFile(content); + return Option.some(config); + }, + (effect) => Effect.orElseSucceed(effect, () => Option.none<TelemetryConfig>()), + ); -export const writeTelemetryConfig = Effect.fnUntraced(function* ( +export const writeTelemetryConfig: ( + config: TelemetryConfig, + configDir: string, +) => TelemetryConfigEffect<void> = Effect.fnUntraced(function* ( config: TelemetryConfig, configDir: string, ) { @@ -83,12 +120,13 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( // Random suffix, not a timestamp: concurrent writers (parallel test files, // two CLI processes) in the same millisecond would otherwise share a tmp // path and race the rename into ENOENT. - const tmpPath = `${configPath}.tmp.${crypto.randomUUID()}`; + const crypto = yield* Crypto.Crypto; + const tmpPath = `${configPath}.tmp.${yield* crypto.randomUUIDv4}`; yield* fs.writeFileString(tmpPath, encodePrettyJson(encodeTelemetryConfig(config)), { mode: 0o600, }); yield* fs.rename(tmpPath, configPath); -}, Effect.orDie); +}); export const getEffectiveConsent = Effect.fnUntraced(function* ( config: Option.Option<TelemetryConfig>, diff --git a/apps/cli/src/shared/telemetry/consent.unit.test.ts b/apps/cli/src/shared/telemetry/consent.unit.test.ts index e2257ee95e..d2ad7dce3b 100644 --- a/apps/cli/src/shared/telemetry/consent.unit.test.ts +++ b/apps/cli/src/shared/telemetry/consent.unit.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect, Layer, Option } from "effect"; +import { Clock, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"; import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; import { mockProjectContext, @@ -13,56 +10,86 @@ import { import { getEffectiveConsent, readTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; -function makeConfig(consent: TelemetryConfig["consent"]): TelemetryConfig { - return { +const makeConfig = (consent: TelemetryConfig["consent"]) => + Effect.map(Clock.currentTimeMillis, (session_last_active): TelemetryConfig => ({ consent, device_id: "test-device", session_id: "test-session", - session_last_active: Date.now(), - }; -} + session_last_active, + })); function withEnv(env: Record<string, string>) { const runtimeInfoLayer = mockRuntimeInfo(); const projectContextLayer = mockProjectContext(); + const envLayer = processEnvLayer(env); return Layer.mergeAll( runtimeInfoLayer, projectContextLayer, - processEnvLayer(env), - cliConfigLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer)), + envLayer, + cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provideMerge(envLayer), + Layer.provideMerge(BunServices.layer), + ), ); } function emptyEnv() { const runtimeInfoLayer = mockRuntimeInfo(); const projectContextLayer = mockProjectContext(); + const envLayer = processEnvLayer(); return Layer.mergeAll( runtimeInfoLayer, projectContextLayer, - processEnvLayer(), - cliConfigLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer)), + envLayer, + cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provideMerge(envLayer), + Layer.provideMerge(BunServices.layer), + ), ); } -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-consent-test-")); -} +const makeTempDir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "supabase-consent-test-" }); +}); -function writeTelemetryFile(dir: string, content: string): void { - writeFileSync(path.join(dir, "telemetry.json"), content); -} +const writeTelemetryFile = (dir: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(dir, "telemetry.json"), content); + }); + +const encodeJson = (value: unknown): Effect.Effect<string, Schema.SchemaError, never> => + Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(value); + +const removeTempDir = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(dir, { recursive: true, force: true }); + }).pipe(Effect.ignore); + +const withTempDir = <A, E, R>(use: (dir: string) => Effect.Effect<A, E, R>) => + Effect.gen(function* () { + const dir = yield* makeTempDir; + return yield* use(dir).pipe(Effect.ensuring(removeTempDir(dir))); + }).pipe(Effect.provide(BunServices.layer)); describe("getEffectiveConsent", () => { it.live("returns denied when DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); it.live("returns denied when SUPABASE_TELEMETRY_DISABLED=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1" }))), ); @@ -76,22 +103,22 @@ describe("getEffectiveConsent", () => { it.live("DO_NOT_TRACK=1 takes precedence over persisted granted consent", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ DO_NOT_TRACK: "1" }))), ); it.live("SUPABASE_TELEMETRY_DISABLED=1 takes precedence over DO_NOT_TRACK=1", () => Effect.gen(function* () { - const consent = yield* getEffectiveConsent(Option.some(makeConfig("granted"))); + const consent = yield* getEffectiveConsent(Option.some(yield* makeConfig("granted"))); expect(consent).toBe("denied"); }).pipe(Effect.provide(withEnv({ SUPABASE_TELEMETRY_DISABLED: "1", DO_NOT_TRACK: "1" }))), ); it.live("returns config consent value when set", () => Effect.gen(function* () { - expect(yield* getEffectiveConsent(Option.some(makeConfig("granted")))).toBe("granted"); - expect(yield* getEffectiveConsent(Option.some(makeConfig("denied")))).toBe("denied"); + expect(yield* getEffectiveConsent(Option.some(yield* makeConfig("granted")))).toBe("granted"); + expect(yield* getEffectiveConsent(Option.some(yield* makeConfig("denied")))).toBe("denied"); }).pipe(Effect.provide(emptyEnv())), ); @@ -105,102 +132,87 @@ describe("getEffectiveConsent", () => { describe("readTelemetryConfig", () => { it.live("decodes a valid telemetry config", () => { - const dir = makeTempDir(); - const expected = makeConfig("denied"); - writeTelemetryFile(dir, JSON.stringify(expected)); - - return Effect.gen(function* () { - const config = yield* readTelemetryConfig(dir); - expect(config).toEqual(Option.some(expected)); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const expected = yield* makeConfig("denied"); + yield* writeTelemetryFile(dir, yield* encodeJson(expected)); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual(Option.some(expected)); + }), ); }); it.live("decodes a legacy disabled telemetry state as denied consent", () => { - const dir = makeTempDir(); - writeTelemetryFile( - dir, - JSON.stringify({ - enabled: false, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeTelemetryFile( + dir, + yield* encodeJson({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "denied", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + }), + ); }), ); - - return Effect.gen(function* () { - const config = yield* readTelemetryConfig(dir); - expect(config).toEqual( - Option.some({ - consent: "denied", - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: Date.parse("2026-04-01T12:00:00Z"), - }), - ); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); }); it.live("decodes a legacy enabled telemetry state as granted consent", () => { - const dir = makeTempDir(); - writeTelemetryFile( - dir, - JSON.stringify({ - enabled: true, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - distinct_id: "user-123", - schema_version: 1, + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeTelemetryFile( + dir, + yield* encodeJson({ + enabled: true, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + distinct_id: "user-123", + schema_version: 1, + }), + ); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual( + Option.some({ + consent: "granted", + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: Date.parse("2026-04-01T12:00:00Z"), + distinct_id: "user-123", + }), + ); }), ); - - return Effect.gen(function* () { - const config = yield* readTelemetryConfig(dir); - expect(config).toEqual( - Option.some({ - consent: "granted", - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: Date.parse("2026-04-01T12:00:00Z"), - distinct_id: "user-123", - }), - ); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); }); it.live("returns none for malformed JSON instead of throwing", () => { - const dir = makeTempDir(); - writeTelemetryFile(dir, ""); - - return Effect.gen(function* () { - const config = yield* readTelemetryConfig(dir); - expect(config).toEqual(Option.none()); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeTelemetryFile(dir, ""); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual(Option.none()); + }), ); }); it.live("returns none for structurally invalid telemetry config", () => { - const dir = makeTempDir(); - writeTelemetryFile(dir, JSON.stringify({ consent: "granted" })); - - return Effect.gen(function* () { - const config = yield* readTelemetryConfig(dir); - expect(config).toEqual(Option.none()); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeTelemetryFile(dir, yield* encodeJson({ consent: "granted" })); + const config = yield* readTelemetryConfig(dir); + expect(config).toEqual(Option.none()); + }), ); }); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts index 7864cb517b..1fd2bc9c61 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -1,5 +1,5 @@ -import { readFileSync, readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Cause, Data, Effect, FileSystem, Path } from "effect"; import * as ts from "typescript/unstable/ast"; import type { ClassLikeDeclaration, Expression, Node, SourceFile } from "typescript/unstable/ast"; import { createVirtualFileSystem } from "typescript/unstable/fs"; @@ -14,6 +14,38 @@ declare global { readonly glob: (patterns: ReadonlyArray<string>) => Record<string, () => Promise<unknown>>; } } + +class CoverageTestError extends Data.TaggedError("CoverageTestError")<{ + readonly cause: unknown; +}> {} + +const fsLayer = BunServices.layer; +const pathJoin = (...parts: ReadonlyArray<string>) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(...parts); + }); +const pathResolve = (...parts: ReadonlyArray<string>) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.resolve(...parts); + }); +const readText = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(pathname); + }); +const readNames = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(pathname); + }); +const isDirectory = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(pathname); + return info.type === "Directory"; + }); import { MANAGED_ERROR_CODES, MANAGED_ERROR_TAG_BY_CODE } from "@supabase/stack/managed-model"; import { CliErrorCategory, @@ -73,41 +105,65 @@ function extendsExpression(node: ClassLikeDeclaration): Expression | undefined { return clause?.types[0]?.expression; } -async function withParsedSources<T>( +function withParsedSources<T>( sources: ReadonlyArray<readonly [fileName: string, source: string]>, visit: (files: ReadonlyMap<string, SourceFile>) => T, -): Promise<T> { - const normalizedSources = sources.map( - ([fileName, source]) => [resolve(fileName), source] as const, - ); - for (const [fileName, source] of normalizedSources) - parserFileSystem.writeFile?.(fileName, source); - const snapshot = await parserApi.updateSnapshot({ - openFiles: normalizedSources.map(([fileName]) => fileName), - fileChanges: { changed: normalizedSources.map(([fileName]) => fileName) }, +): Effect.Effect<T, CoverageTestError, Path.Path> { + return Effect.gen(function* () { + const normalizedSources = yield* Effect.all( + sources.map(([fileName, source]) => + pathResolve(fileName).pipe(Effect.map((normalized) => [normalized, source] as const)), + ), + ); + for (const [fileName, source] of normalizedSources) + parserFileSystem.writeFile?.(fileName, source); + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => + parserApi.updateSnapshot({ + openFiles: normalizedSources.map(([fileName]) => fileName), + fileChanges: { changed: normalizedSources.map(([fileName]) => fileName) }, + }), + catch: (cause) => new CoverageTestError({ cause }), + }), + (snapshot) => + Effect.gen(function* () { + const files = new Map<string, SourceFile>(); + for (const [index, [originalFileName]] of sources.entries()) { + const normalized = normalizedSources[index]; + if (normalized === undefined) + return yield* Effect.die(`failed to normalize ${originalFileName}`); + const [fileName] = normalized; + const project = yield* Effect.tryPromise({ + try: () => snapshot.getDefaultProjectForFile(fileName), + catch: (cause) => new CoverageTestError({ cause }), + }); + const sourceFile = + project === undefined + ? undefined + : yield* Effect.tryPromise({ + try: () => project.program.getSourceFile(fileName), + catch: (cause) => new Cause.UnknownError(cause), + }).pipe(Effect.mapError((cause) => new CoverageTestError({ cause }))); + if (sourceFile === undefined) return yield* Effect.die(`failed to parse ${fileName}`); + files.set(originalFileName, sourceFile); + } + return visit(files); + }), + (snapshot) => + Effect.tryPromise({ + try: () => snapshot.dispose(), + catch: (cause) => new CoverageTestError({ cause }), + }).pipe(Effect.orElseSucceed(() => undefined)), + ); }); - try { - const files = new Map<string, SourceFile>(); - for (const [index, [originalFileName]] of sources.entries()) { - const normalized = normalizedSources[index]; - if (normalized === undefined) throw new Error(`failed to normalize ${originalFileName}`); - const [fileName] = normalized; - const project = await snapshot.getDefaultProjectForFile(fileName); - const sourceFile = await project?.program.getSourceFile(fileName); - if (sourceFile === undefined) throw new Error(`failed to parse ${fileName}`); - files.set(originalFileName, sourceFile); - } - return visit(files); - } finally { - await snapshot.dispose(); - } } -async function withParsedSource<T>( +function withParsedSource<T>( fileName: string, source: string, visit: (file: SourceFile) => T, -): Promise<T> { +): Effect.Effect<T, CoverageTestError, Path.Path> { return withParsedSources([[fileName, source]], (files) => visit(files.get(fileName)!)); } @@ -121,14 +177,16 @@ function hasExportModifier(node: ClassLikeDeclaration): boolean { // every plain `class X extends Error` (untagged classes are fingerprinted by // name). A tagged class contributes its tag once — the heritage call is // claimed by the class rule so the factory rule does not count it again. -async function extractErrorTags( +function extractErrorTags( source: string, fileName = "scan.ts", options: { readonly exportedOnly?: boolean } = {}, ): Promise<Array<string>> { const parseFileName = fileName === "scan.ts" ? `scan-${syntheticFileId++}.ts` : fileName; - return withParsedSource(parseFileName, source, (sourceFile) => - extractErrorTagsFromFile(sourceFile, options), + return Effect.runPromise( + withParsedSource(parseFileName, source, (sourceFile) => + extractErrorTagsFromFile(sourceFile, options), + ).pipe(Effect.provide(fsLayer)), ); } @@ -176,31 +234,109 @@ function extractErrorTagsFromFile( return tags; } -async function scanErrorTags( - root: string, - options: { readonly exportedOnly?: boolean } = {}, -): Promise<Map<string, Array<string>>> { - const tagsByFile = new Map<string, Array<string>>(); - const sources: Array<readonly [string, string]> = []; - const walk = (dir: string) => { - for (const entry of readdirSync(dir)) { - const path = join(dir, entry); - if (statSync(path).isDirectory()) { - walk(path); - continue; +interface ErrorActionabilityDeclaration { + readonly tag: string; + readonly hasOwnGetter: boolean; +} + +function hasOwnActionabilityGetter(node: ClassLikeDeclaration): boolean { + return node.members.some( + (member) => + ts.isGetAccessorDeclaration(member) && + ts.isComputedPropertyName(member.name) && + ts.isIdentifier(member.name.expression) && + member.name.expression.text === "ErrorActionabilityId", + ); +} + +function extractErrorActionabilityDeclarationsFromFile( + sourceFile: SourceFile, +): Array<ErrorActionabilityDeclaration> { + const declarations: Array<ErrorActionabilityDeclaration> = []; + const visit = (node: Node): void => { + if (ts.isClassLikeDeclaration(node)) { + const heritage = extendsExpression(node); + let tag: string | undefined; + if (heritage !== undefined && ts.isCallExpression(heritage)) { + tag = calleeName(heritage.expression).endsWith("Error") + ? stringLiteralText(heritage.arguments[0]) + : undefined; + } else if ( + heritage !== undefined && + ts.isIdentifier(heritage) && + heritage.text === "Error" && + node.name !== undefined + ) { + tag = node.name.text; + } + if (tag !== undefined) { + declarations.push({ tag, hasOwnGetter: hasOwnActionabilityGetter(node) }); } - if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; - sources.push([path, readFileSync(path, "utf8")]); } + node.forEachChild(visit); }; - walk(root); - await withParsedSources(sources, (files) => { - for (const [fileName, sourceFile] of files) { - const tags = extractErrorTagsFromFile(sourceFile, options); - if (tags.length > 0) tagsByFile.set(fileName, tags); - } - }); - return tagsByFile; + sourceFile.forEachChild(visit); + return declarations; +} + +function extractErrorActionabilityDeclarations( + source: string, + fileName = "scan.ts", +): Promise<Array<ErrorActionabilityDeclaration>> { + const parseFileName = fileName === "scan.ts" ? `scan-${syntheticFileId++}.ts` : fileName; + return Effect.runPromise( + withParsedSource(parseFileName, source, (sourceFile) => + extractErrorActionabilityDeclarationsFromFile(sourceFile), + ).pipe(Effect.provide(fsLayer)), + ); +} + +function scanErrorTags( + root: string, + options: { readonly exportedOnly?: boolean } = {}, +): Promise<Map<string, Array<string>>> { + return Effect.runPromise( + Effect.gen(function* () { + const tagsByFile = new Map<string, Array<string>>(); + const sources: Array<readonly [string, string]> = []; + const walk = ( + dir: string, + ): Effect.Effect<void, CoverageTestError, FileSystem.FileSystem | Path.Path> => + Effect.gen(function* () { + const entries = yield* readNames(dir).pipe( + Effect.mapError((cause) => new CoverageTestError({ cause })), + ); + for (const entry of entries) { + const pathname = yield* pathJoin(dir, entry); + const directory = yield* isDirectory(pathname).pipe( + Effect.mapError((cause) => new CoverageTestError({ cause })), + ); + if (directory) { + yield* walk(pathname); + continue; + } + // Deno's function-runtime entrypoint starts a server at import time + // and is not a Bun-loadable CLI module; its runtime errors are + // exercised by the functions integration tests instead. + if (pathname.endsWith("/shared/functions/serve.main.ts")) continue; + if (!pathname.endsWith(".ts") || pathname.endsWith(".test.ts")) continue; + const source = yield* readText(pathname).pipe( + Effect.mapError((cause) => new CoverageTestError({ cause })), + ); + sources.push([pathname, source]); + } + }); + yield* walk(root); + const parsed = yield* withParsedSources(sources, (files) => { + for (const [fileName, sourceFile] of files) { + const tags = extractErrorTagsFromFile(sourceFile, options); + if (tags.length > 0) tagsByFile.set(fileName, tags); + } + return tagsByFile; + }); + return parsed; + }).pipe(Effect.provide(fsLayer)), + ); } describe("extractErrorTags", () => { @@ -276,14 +412,23 @@ function collectErrorClasses(module: Record<string, unknown>): Array<DeclaredErr return classes; } -const srcRoot = resolve(import.meta.dirname, "../.."); -const repoRoot = resolve(import.meta.dirname, "../../../../.."); +const srcRoot = await Effect.runPromise( + pathResolve(import.meta.dirname, "../..").pipe(Effect.provide(fsLayer)), +); +const repoRoot = await Effect.runPromise( + pathResolve(import.meta.dirname, "../../../../..").pipe(Effect.provide(fsLayer)), +); const moduleLoaders = new Map( - Object.entries(import.meta.glob(["../../**/*.ts", "!**/*.test.ts"])).map(([key, loader]) => [ - resolve(import.meta.dirname, key), - loader, - ]), + await Effect.runPromise( + Effect.all( + Object.entries(import.meta.glob(["../../**/*.ts", "!**/*.test.ts"])).map(([key, loader]) => + pathResolve(import.meta.dirname, key).pipe( + Effect.map((pathname) => [pathname, loader] as const), + ), + ), + ).pipe(Effect.provide(fsLayer)), + ), ); const tagsByFile = await scanErrorTags(srcRoot); @@ -293,61 +438,75 @@ describe("apps/cli error classes declare their actionability", () => { expect(tagsByFile.size).toBeGreaterThan(50); }); - for (const [file, tags] of tagsByFile) { + for (const [file] of tagsByFile) { const relativePath = file.slice(srcRoot.length + 1); // Importing a command module can pull in a large transitive graph on first // load; give these dynamic-import tests more headroom than the default 5s. - it(relativePath, { timeout: 30_000 }, async () => { - const loader = moduleLoaders.get(file); - expect(loader, `no module loader for ${relativePath}`).toBeDefined(); - const module = await loader?.(); - expect(typeof module).toBe("object"); - const classes = collectErrorClasses(Object(module)); - - const exportedTags = new Set(classes.map((cls) => cls.tag)); - for (const tag of tags) { - expect( - exportedTags.has(tag), - `error "${tag}" is defined in ${relativePath} but not exported — export it so its actionability declaration is verifiable`, - ).toBe(true); - } - - for (const { constructor, exportName, isTagged, tag, prototype } of classes) { - const descriptor = Object.getOwnPropertyDescriptor(prototype, ErrorActionabilityId); - expect( - typeof descriptor?.get, - `${exportName} ("${tag}") does not declare an own [ErrorActionabilityId] getter — add one returning its CliErrorActionabilityDeclaration`, - ).toBe("function"); - - // Evaluate the getter against a field-less probe: instance-dependent - // declarations must degrade to a valid declaration when fields are - // absent, and static ones are checked directly. - const probe: object = Object.create(prototype); - const declaration: unknown = Reflect.get(probe, ErrorActionabilityId); - expect( - typeof declaration === "object" && declaration !== null, - `${exportName} ("${tag}") declaration is not an object`, - ).toBe(true); - const record: Record<string, unknown> = Object(declaration); - expect(kindValues.has(String(record["error_kind"]))).toBe(true); - expect(categoryValues.has(String(record["error_category"]))).toBe(true); - expect(typeof record["has_suggestion"]).toBe("boolean"); - expect(suggestionValues.has(String(record["suggestion_type"]))).toBe(true); - - if (!isTagged) { - const fingerprintDescriptor = Object.getOwnPropertyDescriptor( - constructor, - ErrorActionabilityFingerprintId, + it(relativePath, { timeout: 30_000 }, () => + Effect.runPromise( + Effect.gen(function* () { + const loader = moduleLoaders.get(file); + expect(loader, `no module loader for ${relativePath}`).toBeDefined(); + if (loader === undefined) return; + const module = yield* Effect.tryPromise({ + try: () => loader(), + catch: (cause) => new CoverageTestError({ cause }), + }); + expect(typeof module).toBe("object"); + const classes = collectErrorClasses(Object(module)); + + const source = yield* readText(file).pipe( + Effect.mapError((cause) => new CoverageTestError({ cause })), ); - expect( - fingerprintDescriptor !== undefined && - "value" in fingerprintDescriptor && - fingerprintDescriptor.value === exportName, - `${exportName} is an untagged Error and must declare its stable source identifier as an own static [ErrorActionabilityFingerprintId] value`, - ).toBe(true); - } - } - }); + const declarations = yield* Effect.tryPromise({ + try: () => extractErrorActionabilityDeclarations(source, file), + catch: (cause) => new CoverageTestError({ cause }), + }); + for (const declaration of declarations) { + expect( + declaration.hasOwnGetter, + `${declaration.tag} in ${relativePath} must declare an own [ErrorActionabilityId] getter`, + ).toBe(true); + } + + for (const { constructor, exportName, isTagged, tag, prototype } of classes) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, ErrorActionabilityId); + expect( + typeof descriptor?.get, + `${exportName} ("${tag}") does not declare an own [ErrorActionabilityId] getter — add one returning its CliErrorActionabilityDeclaration`, + ).toBe("function"); + + // Evaluate the getter against a field-less probe: instance-dependent + // declarations must degrade to a valid declaration when fields are + // absent, and static ones are checked directly. + const probe: object = Object.create(prototype); + const declaration: unknown = Reflect.get(probe, ErrorActionabilityId); + expect( + typeof declaration === "object" && declaration !== null, + `${exportName} ("${tag}") declaration is not an object`, + ).toBe(true); + const record: Record<string, unknown> = Object(declaration); + expect(kindValues.has(String(record["error_kind"]))).toBe(true); + expect(categoryValues.has(String(record["error_category"]))).toBe(true); + expect(typeof record["has_suggestion"]).toBe("boolean"); + expect(suggestionValues.has(String(record["suggestion_type"]))).toBe(true); + + if (!isTagged) { + const fingerprintDescriptor = Object.getOwnPropertyDescriptor( + constructor, + ErrorActionabilityFingerprintId, + ); + expect( + fingerprintDescriptor !== undefined && + "value" in fingerprintDescriptor && + fingerprintDescriptor.value === exportName, + `${exportName} is an untagged Error and must declare its stable source identifier as an own static [ErrorActionabilityFingerprintId] value`, + ).toBe(true); + } + } + }).pipe(Effect.provide(fsLayer)), + ), + ); } }); @@ -360,20 +519,19 @@ describe("workspace package error tags have external adapters", () => { ]; for (const packageRoot of packageRoots) { - it(packageRoot, { timeout: 30_000 }, async () => { - const tagsByFile = await scanErrorTags(resolve(repoRoot, packageRoot), { - exportedOnly: true, - }); - expect(tagsByFile.size).toBeGreaterThan(0); - for (const [file, tags] of tagsByFile) { - for (const tag of tags) { - expect( - isClassifiedExternalErrorTag(tag), - `"${tag}" (${file.slice(repoRoot.length + 1)}) has no external adapter in error-actionability.ts`, - ).toBe(true); + it(packageRoot, { timeout: 30_000 }, () => + scanErrorTags(`${repoRoot}/${packageRoot}`, { exportedOnly: true }).then((tagsByFile) => { + expect(tagsByFile.size).toBeGreaterThan(0); + for (const [file, tags] of tagsByFile) { + for (const tag of tags) { + expect( + isClassifiedExternalErrorTag(tag), + `"${tag}" (${file.slice(repoRoot.length + 1)}) has no external adapter in error-actionability.ts`, + ).toBe(true); + } } - } - }); + }), + ); } }); @@ -391,7 +549,7 @@ interface ManagedErrorClass { // Collects the (class, tag, code) triples of every `class X extends // Data.TaggedError("Tag")` that also declares a string-literal `code` member. -async function scanManagedErrorClasses(path: string): Promise<Array<ManagedErrorClass>> { +function scanManagedErrorClasses(path: string): Promise<Array<ManagedErrorClass>> { const classes: Array<ManagedErrorClass> = []; const visit = (node: ts.Node): void => { if (ts.isClassDeclaration(node) && node.name !== undefined) { @@ -414,46 +572,50 @@ async function scanManagedErrorClasses(path: string): Promise<Array<ManagedError } node.forEachChild(visit); }; - return withParsedSource(path, readFileSync(path, "utf8"), (sourceFile) => { - sourceFile.forEachChild(visit); - return classes; - }); + return Effect.runPromise( + Effect.gen(function* () { + const source = yield* readText(path); + return yield* withParsedSource(path, source, (sourceFile) => { + sourceFile.forEachChild(visit); + return classes; + }); + }).pipe(Effect.provide(fsLayer)), + ); } describe("managed registry error codes are classified", () => { - it("packages/stack/src/managed/model.ts", async () => { - const modelPath = resolve(repoRoot, "packages/stack/src/managed/model.ts"); - const scanned = await scanManagedErrorClasses(modelPath); - // One class per declared code: a class written in a shape this scan cannot - // see would otherwise pass vacuously instead of failing loudly. - expect(scanned.length).toBe(MANAGED_ERROR_CODES.length); - const declaredCodes = new Set<string>(MANAGED_ERROR_CODES); - const scannedCodes = new Set<string>(); - for (const { className, tag, code } of scanned) { - scannedCodes.add(code); - expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe( - className, - ); - expect( - declaredCodes.has(code), - `${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`, - ).toBe(true); - expect( - Reflect.get(MANAGED_ERROR_TAG_BY_CODE, code), - `MANAGED_ERROR_TAG_BY_CODE does not map "${code}" to ${className}`, - ).toBe(tag); - expect( - isClassifiedManagedErrorCode(code), - `${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`, - ).toBe(true); - expect( - isClassifiedExternalErrorTag(tag), - `${className} ("${tag}") has no generated entry in externalActionabilityByTag in error-actionability.ts`, - ).toBe(true); - } - // Every declared code is backed by a class, not just the other way round. - expect([...scannedCodes].sort()).toEqual([...declaredCodes].sort()); - }); + it("packages/stack/src/managed/model.ts", () => + scanManagedErrorClasses(`${repoRoot}/packages/stack/src/managed/model.ts`).then((scanned) => { + // One class per declared code: a class written in a shape this scan cannot + // see would otherwise pass vacuously instead of failing loudly. + expect(scanned.length).toBe(MANAGED_ERROR_CODES.length); + const declaredCodes = new Set<string>(MANAGED_ERROR_CODES); + const scannedCodes = new Set<string>(); + for (const { className, tag, code } of scanned) { + scannedCodes.add(code); + expect(tag, `${className} is tagged "${tag}" rather than its own export name`).toBe( + className, + ); + expect( + declaredCodes.has(code), + `${className}'s code "${code}" is missing from MANAGED_ERROR_CODES`, + ).toBe(true); + expect( + Reflect.get(MANAGED_ERROR_TAG_BY_CODE, code), + `MANAGED_ERROR_TAG_BY_CODE does not map "${code}" to ${className}`, + ).toBe(tag); + expect( + isClassifiedManagedErrorCode(code), + `${className} ("${code}") has no entry in managedActionabilityByCode in error-actionability.ts`, + ).toBe(true); + expect( + isClassifiedExternalErrorTag(tag), + `${className} ("${tag}") has no generated entry in externalActionabilityByTag in error-actionability.ts`, + ).toBe(true); + } + // Every declared code is backed by a class, not just the other way round. + expect([...scannedCodes].sort()).toEqual([...declaredCodes].sort()); + })); }); describe("Effect CLI parser errors have exhaustive handling", () => { diff --git a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts index 4f743b3b06..3f9591dd69 100644 --- a/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability-minified.integration.test.ts @@ -1,80 +1,98 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, test } from "vitest"; +import { BunServices } from "@effect/platform-bun"; +import { Crypto, Data, Effect, FileSystem, Path } from "effect"; + +class MinifiedTestError extends Data.TaggedError("MinifiedTestError")<{ + readonly cause: unknown; +}> {} describe("release-minified error fingerprints", () => { - test("keeps a declared tagged error's source identifier", async () => { - const tempDir = await mkdtemp(join(tmpdir(), "supabase-error-actionability-")); - const bundlePath = join(tempDir, "fixture.mjs"); - const errorModule = resolve(import.meta.dirname, "../functions/delete.errors.ts"); - const plainErrorModule = resolve( - import.meta.dirname, - "../../legacy/shared/legacy-config-validate.ts", - ); - const classifierModule = resolve(import.meta.dirname, "error-actionability.ts"); + test("keeps a declared tagged error's source identifier", () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const tempDir = yield* fs.makeTempDirectory({ prefix: "supabase-error-actionability-" }); + const bundlePath = path.join(tempDir, "fixture.mjs"); + const errorModule = path.join(import.meta.dirname, "../functions/delete.errors.ts"); + const plainErrorModule = path.join( + import.meta.dirname, + "../../legacy/shared/legacy-config-validate.ts", + ); + const classifierModule = path.join(import.meta.dirname, "error-actionability.ts"); - try { - const build = await Bun.build({ - entrypoints: ["actionability-fixture"], - target: "bun", - minify: true, - plugins: [ - { - name: "actionability-fixture", - setup(builder) { - builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ - path: "actionability-fixture", - namespace: "actionability-fixture", - })); - builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ - contents: ` - import { InvalidFunctionSlugError } from ${JSON.stringify(errorModule)}; - import { LegacyConfigValidateError } from ${JSON.stringify(plainErrorModule)}; - import { classifyCliErrorActionability } from ${JSON.stringify(classifierModule)}; - export const taggedConstructorName = InvalidFunctionSlugError.name; - export const taggedClassification = classifyCliErrorActionability( - new InvalidFunctionSlugError({ message: "private user input" }), - ); - export const plainConstructorName = LegacyConfigValidateError.name; - export const plainClassification = classifyCliErrorActionability( - new LegacyConfigValidateError("private user input"), - ); - `, - loader: "ts", - })); - }, - }, - ], - }); + const build = yield* Effect.tryPromise({ + try: () => + Bun.build({ + entrypoints: ["actionability-fixture"], + target: "bun", + minify: true, + plugins: [ + { + name: "actionability-fixture", + setup(builder) { + builder.onResolve({ filter: /^actionability-fixture$/ }, () => ({ + path: "actionability-fixture", + namespace: "actionability-fixture", + })); + builder.onLoad({ filter: /.*/, namespace: "actionability-fixture" }, () => ({ + contents: ` + import { InvalidFunctionSlugError } from ${JSON.stringify(errorModule)}; + import { LegacyConfigValidateError } from ${JSON.stringify(plainErrorModule)}; + import { classifyCliErrorActionability } from ${JSON.stringify(classifierModule)}; + export const taggedConstructorName = InvalidFunctionSlugError.name; + export const taggedClassification = classifyCliErrorActionability( + new InvalidFunctionSlugError({ message: "private user input" }), + ); + export const plainConstructorName = LegacyConfigValidateError.name; + export const plainClassification = classifyCliErrorActionability( + new LegacyConfigValidateError("private user input"), + ); + `, + loader: "ts", + })); + }, + }, + ], + }), + catch: (cause) => new MinifiedTestError({ cause }), + }); - expect(build.success, build.logs.map(String).join("\n")).toBe(true); - expect(build.outputs).toHaveLength(1); - const output = build.outputs[0]; - expect(output).toBeDefined(); - if (output === undefined) return; + expect(build.success, build.logs.map(String).join("\n")).toBe(true); + expect(build.outputs).toHaveLength(1); + const output = build.outputs[0]; + expect(output).toBeDefined(); + if (output === undefined) return; - await Bun.write(bundlePath, output); - const fixture = await import(`${pathToFileURL(bundlePath).href}?run=${crypto.randomUUID()}`); - expect(Reflect.get(fixture, "taggedConstructorName")).not.toBe("InvalidFunctionSlugError"); - expect(Reflect.get(fixture, "taggedClassification")).toEqual({ - error_kind: "user_actionable", - error_category: "invalid_input", - error_fingerprint: "tag:InvalidFunctionSlugError", - has_suggestion: true, - suggestion_type: "provide_flags", - }); - expect(Reflect.get(fixture, "plainConstructorName")).not.toBe("LegacyConfigValidateError"); - expect(Reflect.get(fixture, "plainClassification")).toEqual({ - error_kind: "user_actionable", - error_category: "invalid_config", - error_fingerprint: "error:LegacyConfigValidateError", - has_suggestion: true, - suggestion_type: "update_config", - }); - } finally { - await rm(tempDir, { recursive: true, force: true }); - } - }); + const bundleBytes = yield* Effect.tryPromise({ + try: () => output.arrayBuffer(), + catch: (cause) => new MinifiedTestError({ cause }), + }); + yield* fs.writeFile(bundlePath, new Uint8Array(bundleBytes)); + const runId = yield* crypto.randomUUIDv4; + const fixture = yield* Effect.tryPromise({ + try: () => import(`${pathToFileURL(bundlePath).href}?run=${runId}`), + catch: (cause) => new MinifiedTestError({ cause }), + }); + expect(Reflect.get(fixture, "taggedConstructorName")).not.toBe("InvalidFunctionSlugError"); + expect(Reflect.get(fixture, "taggedClassification")).toEqual({ + error_kind: "user_actionable", + error_category: "invalid_input", + error_fingerprint: "tag:InvalidFunctionSlugError", + has_suggestion: true, + suggestion_type: "provide_flags", + }); + expect(Reflect.get(fixture, "plainConstructorName")).not.toBe("LegacyConfigValidateError"); + expect(Reflect.get(fixture, "plainClassification")).toEqual({ + error_kind: "user_actionable", + error_category: "invalid_config", + error_fingerprint: "error:LegacyConfigValidateError", + has_suggestion: true, + suggestion_type: "update_config", + }); + yield* fs.remove(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(BunServices.layer)), + )); }); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index e6381689f0..01a5a81dfc 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -684,7 +684,7 @@ function readDeclaration(error: unknown): CliErrorActionabilityDeclaration | und if (!isErrorRecord(prototype)) return undefined; const descriptor = Object.getOwnPropertyDescriptor(prototype, ErrorActionabilityId); if (descriptor?.get === undefined) return undefined; - return sanitizeDeclaration(Reflect.apply(descriptor.get, error, [])); + return sanitizeDeclaration(Reflect.get(error, ErrorActionabilityId)); } catch { return undefined; } @@ -942,6 +942,7 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = { MissingProjectConfigValueError: () => actionability.invalidConfig, DuplicateRemoteProjectIdError: () => actionability.invalidConfig, InvalidRemoteProjectIdError: () => actionability.invalidConfig, + ProjectConfigStoreError: () => actionability.invalidConfig, // @supabase/api — client construction failed before any request (missing // access token / bad configuration); remediation is the token env var. @@ -991,6 +992,7 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = { BinaryManifestError: () => actionability.externalNetwork, BinaryRuntimeError: () => actionability.externalNetwork, BinaryHostCompatibilityError: () => actionability.invalidConfig, + FunctionsBundlePathError: () => actionability.invalidInput, DownloadError: () => actionability.externalNetwork, ChecksumMismatchError: () => ({ ...actionability.externalNetwork, @@ -1098,6 +1100,10 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = { fingerprint_suffix: "managed_workspace_repair", }), DaemonStartError: () => actionability.unknown, + DaemonLaunchUpdateError: () => ({ + ...actionability.startStack, + fingerprint_suffix: "daemon_start", + }), SupervisorStartError: () => ({ ...actionability.startStack, fingerprint_suffix: "daemon_start", @@ -1128,6 +1134,8 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = { MissingDependencyError: () => actionability.impossibleState, ServiceNotFoundError: () => actionability.impossibleState, SpawnError: () => actionability.startStack, + HookExecutionError: () => actionability.impossibleState, + CleanupExecutionError: () => actionability.impossibleState, ShutdownTimeoutError: () => actionability.stopStack, ServiceReadyError: () => actionability.startStack, }; @@ -1198,7 +1206,12 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { if (declared !== undefined) { const stableTaggedName = readStableTaggedPrototypeName(error); const tag = readErrorTag(error); - if (stableTaggedName !== undefined && tag === stableTaggedName) { + const declaredFingerprint = readDeclaredErrorFingerprintId(error); + if ( + declaredFingerprint === undefined && + stableTaggedName !== undefined && + tag === stableTaggedName + ) { return toActionability(declared, "tag", tag); } // The declared static identifier outranks the prototype walk: a class @@ -1207,7 +1220,11 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { return toActionability( declared, "error", - readDeclaredErrorFingerprintId(error) ?? stableTaggedName ?? "DeclaredError", + declaredFingerprint ?? + (stableTaggedName !== undefined && tag === stableTaggedName + ? stableTaggedName + : undefined) ?? + "DeclaredError", ); } diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts index 8f94bdbf4c..a67dcd0f6a 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -48,7 +48,9 @@ class DeclaredNoSuggestionError extends Data.TaggedError("DeclaredNoSuggestionEr } } -class PlainDeclaredError extends Error { +class PlainDeclaredError extends Data.TaggedError("PlainDeclaredError")<{ + readonly message: string; +}> { static readonly [ErrorActionabilityFingerprintId] = "PlainDeclaredError"; get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -56,23 +58,18 @@ class PlainDeclaredError extends Error { } } -class DeclarationCarrierError extends Error { - readonly _tag: string; +class DeclarationCarrierError extends Data.TaggedError("DeclarationCarrierError")<{ readonly declaration: Record<string, unknown>; - - constructor(tag: string, declaration: Record<string, unknown>) { - super(tag); - this._tag = tag; - this.declaration = declaration; - } - +}> { get [ErrorActionabilityId](): Record<string, unknown> { return this.declaration; } } function declarationCarrier(tag: string, declaration: Record<string, unknown>) { - return new DeclarationCarrierError(tag, declaration); + const error = new DeclarationCarrierError({ declaration }); + Reflect.set(error, "_tag", tag); + return error; } describe("classifyCliErrorActionability", () => { @@ -90,7 +87,9 @@ describe("classifyCliErrorActionability", () => { }); it("uses a declared Error subclass constructor for an untagged fingerprint", () => { - const result = classifyCliErrorActionability(new PlainDeclaredError("private path")); + const result = classifyCliErrorActionability( + new PlainDeclaredError({ message: "private path" }), + ); expect(result.error_category).toBe("invalid_config"); expect(result.error_fingerprint).toBe("error:PlainDeclaredError"); }); @@ -99,14 +98,18 @@ describe("classifyCliErrorActionability", () => { // TypeError.prototype carries an own `name` data property ("TypeError"); // without the static-identifier precedence, every declared class extending // a native Error subtype would collide on the native name. - class NativeSubtypeDeclaredError extends TypeError { + class NativeSubtypeDeclaredError extends Data.TaggedError("NativeSubtypeDeclaredError")<{ + readonly message: string; + }> { static readonly [ErrorActionabilityFingerprintId] = "NativeSubtypeDeclaredError"; get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.invalidConfig; } } - const result = classifyCliErrorActionability(new NativeSubtypeDeclaredError("boom")); + const result = classifyCliErrorActionability( + new NativeSubtypeDeclaredError({ message: "boom" }), + ); expect(result.error_category).toBe("invalid_config"); expect(result.error_fingerprint).toBe("error:NativeSubtypeDeclaredError"); }); @@ -278,9 +281,7 @@ describe("classifyCliErrorActionability", () => { it("ignores a declaration whose symbol getter throws", () => { const secret = "token-from-throwing-getter"; - class ThrowingDeclarationError extends Error { - readonly _tag = "ThrowingDeclarationError"; - + class ThrowingDeclarationError extends Data.TaggedError("ThrowingDeclarationError")<{}> { get [ErrorActionabilityId]() { throw new Error(secret); } @@ -432,16 +433,18 @@ describe("classifyCliErrorActionability", () => { it("does not accept declarations through the global symbol registry", () => { const secret = "CustomerProjectRef123"; const globalDeclarationKey = Symbol.for("@supabase/cli/telemetry/ErrorActionability"); - class HostileDeclaredError extends Error { - readonly _tag = secret; - + class HostileDeclaredError extends Data.TaggedError("HostileDeclaredError")<{ + readonly message: string; + }> { get [globalDeclarationKey](): CliErrorActionabilityDeclaration { return actionability.invalidInput; } } Object.defineProperty(HostileDeclaredError, "name", { value: secret }); - const result = classifyCliErrorActionability(new HostileDeclaredError(secret)); + const error = new HostileDeclaredError({ message: secret }); + Reflect.set(error, "_tag", secret); + const result = classifyCliErrorActionability(error); expect(result.error_fingerprint).toBe("tag:unknown"); expect(JSON.stringify(result)).not.toContain(secret); }); @@ -691,13 +694,18 @@ describe("classifyCliErrorActionability", () => { }); it("fingerprints workspaceCause without populating native Error.cause", () => { - const error = new (class extends Error { - readonly _tag = "UnsupportedGitWorkspaceError"; - readonly code = "UNSUPPORTED_GIT_WORKSPACE"; - readonly path = "/private/project/.git"; - readonly reason = "metadata is inaccessible"; - readonly workspaceCause = "metadata-inaccessible" as const; - })(); + class WorkspaceCauseError extends Data.TaggedError("UnsupportedGitWorkspaceError")<{ + readonly code: string; + readonly path: string; + readonly reason: string; + readonly workspaceCause: "metadata-inaccessible"; + }> {} + const error = new WorkspaceCauseError({ + code: "UNSUPPORTED_GIT_WORKSPACE", + path: "/private/project/.git", + reason: "metadata is inaccessible", + workspaceCause: "metadata-inaccessible", + }); expect(error.cause).toBeUndefined(); expect(classifyCliErrorActionability(error).error_fingerprint).toBe( "tag:UnsupportedGitWorkspaceError:managed_git_workspace_metadata_inaccessible", diff --git a/apps/cli/src/shared/telemetry/exporters/debug-console.ts b/apps/cli/src/shared/telemetry/exporters/debug-console.ts index 26ce4a8037..07d76d3d51 100644 --- a/apps/cli/src/shared/telemetry/exporters/debug-console.ts +++ b/apps/cli/src/shared/telemetry/exporters/debug-console.ts @@ -1,12 +1,8 @@ +import { DateTime } from "effect"; import type { Tracer } from "effect"; function formatTimestamp(ms: number): string { - const d = new Date(ms); - const h = String(d.getHours()).padStart(2, "0"); - const m = String(d.getMinutes()).padStart(2, "0"); - const s = String(d.getSeconds()).padStart(2, "0"); - const mil = String(d.getMilliseconds()).padStart(3, "0"); - return `${h}:${m}:${s}.${mil}`; + return DateTime.formatIso(DateTime.makeUnsafe(ms)).split("T")[1]!.slice(0, 12); } export function formatSpanForDebugConsole(span: Tracer.Span): string | undefined { diff --git a/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts b/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts index efcea1d750..d119ed33cc 100644 --- a/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts +++ b/apps/cli/src/shared/telemetry/exporters/debug-console.unit.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "vitest"; -import { Option, Context, Tracer } from "effect"; +import { Clock, Context, Effect, Option, Tracer } from "effect"; import { formatSpanForDebugConsole, makeDebugConsoleExporter } from "./debug-console.ts"; function makeEndedSpan(name: string, attrs: Record<string, unknown> = {}): Tracer.Span { - const startTime = BigInt(Date.now()) * BigInt(1_000_000); + const startTime = BigInt(Effect.runSync(Clock.currentTimeMillis)) * BigInt(1_000_000); const endTime = startTime + BigInt(50_000_000); // 50ms later const attributes = new Map(Object.entries(attrs)); return { @@ -51,7 +51,7 @@ describe("debug-console exporter", () => { ...makeEndedSpan("pending-span"), status: { _tag: "Started", - startTime: BigInt(Date.now()) * BigInt(1_000_000), + startTime: BigInt(Effect.runSync(Clock.currentTimeMillis)) * BigInt(1_000_000), } as Tracer.SpanStatus, }; diff --git a/apps/cli/src/shared/telemetry/exporters/ndjson.ts b/apps/cli/src/shared/telemetry/exporters/ndjson.ts index 3ebe5c947b..31e7ad5a4a 100644 --- a/apps/cli/src/shared/telemetry/exporters/ndjson.ts +++ b/apps/cli/src/shared/telemetry/exporters/ndjson.ts @@ -1,63 +1,70 @@ -import { appendFileSync } from "node:fs"; -import { Effect, FileSystem, Path } from "effect"; +import { Clock, DateTime, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import type { Tracer } from "effect"; const RETENTION_DAYS = 7; +const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); -export const initNdjsonExporter = Effect.fnUntraced( +type NdjsonEffect<A> = Effect.Effect<A, never, FileSystem.FileSystem | Path.Path>; + +export const initNdjsonExporter: (tracesDir: string) => NdjsonEffect<void> = Effect.fnUntraced( function* (tracesDir: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fs.makeDirectory(tracesDir, { recursive: true, mode: 0o700 }); const files = yield* fs.readDirectory(tracesDir); - const cutoff = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000; + const cutoff = (yield* Clock.currentTimeMillis) - RETENTION_DAYS * 24 * 60 * 60 * 1000; for (const file of files) { if (!file.endsWith(".ndjson")) continue; const dateStr = file.replace(".ndjson", ""); - const fileDate = new Date(dateStr).getTime(); - if (!Number.isNaN(fileDate) && fileDate < cutoff) { + const fileDate = DateTime.make(dateStr); + if (Option.isSome(fileDate) && fileDate.value.epochMilliseconds < cutoff) { yield* fs.remove(path.join(tracesDir, file)); } } }, - (effect, _tracesDir) => Effect.ignore(effect), + (effect) => Effect.ignore(effect), ); -export function exportSpanToNdjson(span: Tracer.Span, tracesDir: string): void { - const status = span.status; - if (status._tag !== "Ended") return; - - const durationMs = Number(status.endTime - status.startTime) / 1_000_000; - const timestampMs = Number(status.startTime / BigInt(1_000_000)); - - const attributes: Record<string, unknown> = {}; - for (const [key, value] of span.attributes) { - attributes[key] = value; - } - - let errorCode: string | undefined; - if (status.exit._tag !== "Success") { - const exitStr = JSON.stringify(status.exit); - const match = exitStr.match(/"_tag"\s*:\s*"([^"]+)"/); - if (match) errorCode = match[1]; - } - - const line = JSON.stringify({ - timestamp: new Date(timestampMs).toISOString(), - traceId: span.traceId, - spanId: span.spanId, - name: span.name, - duration_ms: Math.round(durationMs), - status: status.exit._tag === "Success" ? "ok" : "error", - ...(errorCode && { error_code: errorCode }), - attributes, - }); - - try { - const date = new Date().toISOString().split("T")[0]; - appendFileSync(`${tracesDir}/${date}.ndjson`, `${line}\n`); - } catch { - // ignore write errors - } -} +export const exportSpanToNdjson: (span: Tracer.Span, tracesDir: string) => NdjsonEffect<void> = + Effect.fnUntraced( + function* (span: Tracer.Span, tracesDir: string) { + const status = span.status; + if (status._tag !== "Ended") return; + + const durationMs = Number(status.endTime - status.startTime) / 1_000_000; + const timestampMs = Number(status.startTime / BigInt(1_000_000)); + + const attributes: Record<string, unknown> = {}; + for (const [key, value] of span.attributes) { + attributes[key] = value; + } + + let errorCode: string | undefined; + if (Exit.isFailure(status.exit)) { + errorCode = "Failure"; + } + + const line = encodeJson({ + timestamp: DateTime.formatIso(DateTime.makeUnsafe(timestampMs)), + traceId: span.traceId, + spanId: span.spanId, + name: span.name, + duration_ms: Math.round(durationMs), + status: Exit.isSuccess(status.exit) ? "ok" : "error", + ...(errorCode && { error_code: errorCode }), + attributes, + }); + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const date = DateTime.formatIsoDateUtc(DateTime.makeUnsafe(yield* Clock.currentTimeMillis)); + yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(path.join(tracesDir, `${date}.ndjson`), { flag: "a" }); + yield* file.writeAll(new TextEncoder().encode(`${line}\n`)); + }), + ); + }, + (effect) => Effect.ignore(effect), + ); diff --git a/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts b/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts index deb9649327..c072092adf 100644 --- a/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts +++ b/apps/cli/src/shared/telemetry/exporters/ndjson.unit.test.ts @@ -1,22 +1,18 @@ import { describe, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect } from "effect"; +import { Effect, FileSystem } from "effect"; import { initNdjsonExporter } from "./ndjson.ts"; const fsLayer = BunServices.layer; describe("initNdjsonExporter", () => { it.live("does not fail when traces directory does not exist", () => { - const dir = mkdtempSync(path.join(tmpdir(), "supabase-ndjson-test-")); - const tracesDir = path.join(dir, "traces"); return Effect.gen(function* () { - yield* initNdjsonExporter(tracesDir); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), - ); + const fs = yield* FileSystem.FileSystem; + const dir = yield* fs.makeTempDirectory({ prefix: "supabase-ndjson-test-" }); + yield* initNdjsonExporter(`${dir}/traces`).pipe( + Effect.ensuring(fs.remove(dir, { recursive: true, force: true }).pipe(Effect.ignore)), + ); + }).pipe(Effect.provide(fsLayer)); }); }); diff --git a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts index 1975bfa6cc..3a00618b0d 100644 --- a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts @@ -1,5 +1,4 @@ -import { createServer, type Server } from "node:http"; -import { gunzipSync } from "node:zlib"; +import { Effect, Schema } from "effect"; import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest"; import { runSupabase } from "../../../tests/helpers/cli.ts"; @@ -9,39 +8,44 @@ type CapturedEvent = { }; describe("failed command telemetry", () => { - let server: Server; + let server: { readonly port: number; readonly stop: () => Promise<void> }; let host: string; const capturedEvents: CapturedEvent[] = []; - beforeAll(async () => { - server = createServer((request, response) => { - const chunks: Buffer[] = []; - request.on("data", (chunk: Buffer) => chunks.push(chunk)); - request.on("end", () => { - const body = Buffer.concat(chunks); - const decoded = request.headers["content-encoding"] === "gzip" ? gunzipSync(body) : body; - const payload: unknown = JSON.parse(decoded.toString()); - if (typeof payload === "object" && payload !== null) { - const batch = Reflect.get(payload, "batch"); - if (Array.isArray(batch)) capturedEvents.push(...batch); - } - response.writeHead(200, { "content-type": "application/json" }); - response.end("{}"); - }); + beforeAll(() => { + const runningServer = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: (request) => + Effect.runPromise( + Effect.gen(function* () { + const body = yield* Effect.tryPromise(() => request.arrayBuffer()); + const decoded = + request.headers.get("content-encoding") === "gzip" + ? yield* Effect.tryPromise(() => + new Response( + new Blob([body]).stream().pipeThrough(new DecompressionStream("gzip")), + ).text(), + ) + : new TextDecoder().decode(body); + const payload = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + decoded, + ); + if (typeof payload === "object" && payload !== null) { + const batch = Reflect.get(payload, "batch"); + if (Array.isArray(batch)) capturedEvents.push(...batch); + } + return new Response("{}", { headers: { "content-type": "application/json" } }); + }), + ), }); - await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Failed to allocate a telemetry receiver port"); - } - host = `http://127.0.0.1:${address.port}`; + const { port } = runningServer; + if (port === undefined) throw new Error("Failed to allocate a telemetry receiver port"); + server = { port, stop: () => runningServer.stop() }; + host = `http://127.0.0.1:${server.port}`; }); - afterAll(async () => { - await new Promise<void>((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); - }); - }); + afterAll(() => server.stop()); beforeEach(() => { capturedEvents.length = 0; @@ -84,8 +88,8 @@ describe("failed command telemetry", () => { }, rawErrors: ["failed to connect", "127.0.0.1", "select 1"], }, - ])("emits sanitized metadata from the compiled $entrypoint shell", async (testCase) => { - const result = await runSupabase(testCase.args, { + ])("emits sanitized metadata from the compiled $entrypoint shell", (testCase) => + runSupabase(testCase.args, { entrypoint: testCase.entrypoint, env: { SUPABASE_ACCESS_TOKEN: "", @@ -94,18 +98,18 @@ describe("failed command telemetry", () => { SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_failure_metadata_e2e", SUPABASE_TELEMETRY_POSTHOG_HOST: host, }, - }); - - expect(result.exitCode).toBe(1); - const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); - expect(event).toBeDefined(); - expect(event?.properties).toMatchObject({ - command: testCase.command, - exit_code: 1, - ...testCase.expected, - }); - expect(event?.properties).not.toHaveProperty("workflow"); - const encoded = JSON.stringify(event); - for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); - }); + }).then((result) => { + expect(result.exitCode).toBe(1); + const event = capturedEvents.find((candidate) => candidate.event === "cli_command_executed"); + expect(event).toBeDefined(); + expect(event?.properties).toMatchObject({ + command: testCase.command, + exit_code: 1, + ...testCase.expected, + }); + expect(event?.properties).not.toHaveProperty("workflow"); + const encoded = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(event); + for (const rawError of testCase.rawErrors) expect(encoded).not.toContain(rawError); + }), + ); }); diff --git a/apps/cli/src/shared/telemetry/identity.ts b/apps/cli/src/shared/telemetry/identity.ts index 13df5e37d5..c3c6ed1eb9 100644 --- a/apps/cli/src/shared/telemetry/identity.ts +++ b/apps/cli/src/shared/telemetry/identity.ts @@ -1,18 +1,34 @@ -import { Effect, Option } from "effect"; +import { Clock, Crypto, Effect, Option } from "effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import { TelemetryConfigError } from "./consent.ts"; import { readTelemetryConfig, writeTelemetryConfig } from "./consent.ts"; import type { TelemetryConfig } from "./types.ts"; const SESSION_TIMEOUT_MS = 30 * 60 * 1000; -export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { +type IdentityEffect<A> = Effect.Effect< + A, + TelemetryConfigError | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Crypto.Crypto +>; + +export const resolveIdentity: (configDir: string) => IdentityEffect<{ + readonly deviceId: string; + readonly sessionId: string; + readonly distinctId: string | undefined; + readonly isFirstRun: boolean; +}> = Effect.fnUntraced(function* (configDir: string) { const config = yield* readTelemetryConfig(configDir); - const now = Date.now(); + const now = yield* Clock.currentTimeMillis; + const crypto = yield* Crypto.Crypto; if (Option.isNone(config)) { const newConfig: TelemetryConfig = { consent: "granted", - device_id: crypto.randomUUID(), - session_id: crypto.randomUUID(), + device_id: yield* crypto.randomUUIDv4, + session_id: yield* crypto.randomUUIDv4, session_last_active: now, }; yield* writeTelemetryConfig(newConfig, configDir); @@ -26,7 +42,7 @@ export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { const currentConfig = config.value; const isSessionExpired = now - currentConfig.session_last_active > SESSION_TIMEOUT_MS; - const sessionId = isSessionExpired ? crypto.randomUUID() : currentConfig.session_id; + const sessionId = isSessionExpired ? yield* crypto.randomUUIDv4 : currentConfig.session_id; yield* writeTelemetryConfig( { ...currentConfig, session_id: sessionId, session_last_active: now }, @@ -40,21 +56,22 @@ export const resolveIdentity = Effect.fnUntraced(function* (configDir: string) { }; }); -export const saveDistinctId = Effect.fnUntraced(function* (configDir: string, distinctId: string) { - const identity = yield* resolveIdentity(configDir); - const config = yield* readTelemetryConfig(configDir); - const nextConfig: TelemetryConfig = { - consent: Option.match(config, { - onNone: () => "granted", - onSome: (value) => value.consent, - }), - device_id: identity.deviceId, - session_id: identity.sessionId, - session_last_active: Date.now(), - distinct_id: distinctId, - }; - yield* writeTelemetryConfig(nextConfig, configDir); -}); +export const saveDistinctId: (configDir: string, distinctId: string) => IdentityEffect<void> = + Effect.fnUntraced(function* (configDir: string, distinctId: string) { + const identity = yield* resolveIdentity(configDir); + const config = yield* readTelemetryConfig(configDir); + const nextConfig: TelemetryConfig = { + consent: Option.match(config, { + onNone: () => "granted", + onSome: (value) => value.consent, + }), + device_id: identity.deviceId, + session_id: identity.sessionId, + session_last_active: yield* Clock.currentTimeMillis, + distinct_id: distinctId, + }; + yield* writeTelemetryConfig(nextConfig, configDir); + }); /** * True when `~/.supabase/` will not survive this invocation (CI runners, @@ -102,32 +119,37 @@ export function makeTelemetryIdentity(persisted: string | undefined): TelemetryI * as a different account then aliases a fresh device. Transient failure * paths use clearDistinctId, which keeps the device id. */ -export const resetIdentity = Effect.fnUntraced(function* (configDir: string) { - const identity = yield* resolveIdentity(configDir); - const config = yield* readTelemetryConfig(configDir); - const nextConfig: TelemetryConfig = { - consent: Option.match(config, { - onNone: () => "granted", - onSome: (value) => value.consent, - }), - device_id: crypto.randomUUID(), - session_id: identity.sessionId, - session_last_active: Date.now(), - }; - yield* writeTelemetryConfig(nextConfig, configDir); -}); +export const resetIdentity: (configDir: string) => IdentityEffect<void> = Effect.fnUntraced( + function* (configDir: string) { + const identity = yield* resolveIdentity(configDir); + const config = yield* readTelemetryConfig(configDir); + const crypto = yield* Crypto.Crypto; + const nextConfig: TelemetryConfig = { + consent: Option.match(config, { + onNone: () => "granted", + onSome: (value) => value.consent, + }), + device_id: yield* crypto.randomUUIDv4, + session_id: identity.sessionId, + session_last_active: yield* Clock.currentTimeMillis, + }; + yield* writeTelemetryConfig(nextConfig, configDir); + }, +); -export const clearDistinctId = Effect.fnUntraced(function* (configDir: string) { - const identity = yield* resolveIdentity(configDir); - const config = yield* readTelemetryConfig(configDir); - const nextConfig: TelemetryConfig = { - consent: Option.match(config, { - onNone: () => "granted", - onSome: (value) => value.consent, - }), - device_id: identity.deviceId, - session_id: identity.sessionId, - session_last_active: Date.now(), - }; - yield* writeTelemetryConfig(nextConfig, configDir); -}); +export const clearDistinctId: (configDir: string) => IdentityEffect<void> = Effect.fnUntraced( + function* (configDir: string) { + const identity = yield* resolveIdentity(configDir); + const config = yield* readTelemetryConfig(configDir); + const nextConfig: TelemetryConfig = { + consent: Option.match(config, { + onNone: () => "granted", + onSome: (value) => value.consent, + }), + device_id: identity.deviceId, + session_id: identity.sessionId, + session_last_active: yield* Clock.currentTimeMillis, + }; + yield* writeTelemetryConfig(nextConfig, configDir); + }, +); diff --git a/apps/cli/src/shared/telemetry/identity.unit.test.ts b/apps/cli/src/shared/telemetry/identity.unit.test.ts index cdb8dbe672..4ca7d9adb5 100644 --- a/apps/cli/src/shared/telemetry/identity.unit.test.ts +++ b/apps/cli/src/shared/telemetry/identity.unit.test.ts @@ -1,185 +1,185 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect } from "effect"; +import { Clock, Effect, FileSystem, Schema } from "effect"; import { makeTelemetryIdentity, resetIdentity, resolveIdentity } from "./identity.ts"; -import type { TelemetryConfig } from "./types.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-identity-test-")); -} +const makeTempDir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "supabase-identity-test-" }); +}); + +const writeConfig = (dir: string, config: TelemetryConfig) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const encoded = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(TelemetryConfigSchema))( + config, + ); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(`${dir}/telemetry.json`, encoded); + }); -function writeConfig(dir: string, config: TelemetryConfig): void { - mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); -} +const readConfig = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const content = yield* fs.readFileString(`${dir}/telemetry.json`); + return yield* Schema.decodeEffect(Schema.fromJsonString(TelemetryConfigSchema))(content); + }); -function readConfig(dir: string): TelemetryConfig { - return JSON.parse(readFileSync(path.join(dir, "telemetry.json"), "utf8")); -} +const removeTempDir = (dir: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(dir, { recursive: true, force: true }); + }).pipe(Effect.ignore); const fsLayer = BunServices.layer; +const withTempDir = <A, E, R>(use: (dir: string) => Effect.Effect<A, E, R>) => + Effect.gen(function* () { + const dir = yield* makeTempDir; + return yield* use(dir).pipe(Effect.ensuring(removeTempDir(dir))); + }).pipe(Effect.provide(fsLayer)); + describe("resolveIdentity", () => { it.live("generates new device_id on first run", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { deviceId } = yield* resolveIdentity(dir); - expect(deviceId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const { deviceId } = yield* resolveIdentity(dir); + expect(deviceId).toMatch(UUID_PATTERN); + }), ); }); it.live("generates new session_id on first run", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { sessionId } = yield* resolveIdentity(dir); - expect(sessionId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const { sessionId } = yield* resolveIdentity(dir); + expect(sessionId).toMatch(UUID_PATTERN); + }), ); }); it.live("isFirstRun is true on first call", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - const { isFirstRun } = yield* resolveIdentity(dir); - expect(isFirstRun).toBe(true); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const { isFirstRun } = yield* resolveIdentity(dir); + expect(isFirstRun).toBe(true); + }), ); }); it.live("writes config on first run with granted consent", () => { - const dir = makeTempDir(); - return Effect.gen(function* () { - yield* resolveIdentity(dir); - const config = readConfig(dir); - expect(config.consent).toBe("granted"); - expect(config.device_id).toMatch(UUID_PATTERN); - expect(config.session_id).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + yield* resolveIdentity(dir); + const config = yield* readConfig(dir); + expect(config.consent).toBe("granted"); + expect(config.device_id).toMatch(UUID_PATTERN); + expect(config.session_id).toMatch(UUID_PATTERN); + }), ); }); it.live("preserves device_id across runs", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { - const { deviceId } = yield* resolveIdentity(dir); - expect(deviceId).toBe("existing-device-id"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: yield* Clock.currentTimeMillis, + }); + const { deviceId } = yield* resolveIdentity(dir); + expect(deviceId).toBe("existing-device-id"); + }), ); }); it.live("isFirstRun is false on subsequent runs", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { - const { isFirstRun } = yield* resolveIdentity(dir); - expect(isFirstRun).toBe(false); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: yield* Clock.currentTimeMillis, + }); + const { isFirstRun } = yield* resolveIdentity(dir); + expect(isFirstRun).toBe(false); + }), ); }); it.live("preserves session_id within 30min", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now() - 10 * 60 * 1000, - }); - return Effect.gen(function* () { - const { sessionId } = yield* resolveIdentity(dir); - expect(sessionId).toBe("existing-session-id"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: now - 10 * 60 * 1000, + }); + const { sessionId } = yield* resolveIdentity(dir); + expect(sessionId).toBe("existing-session-id"); + }), ); }); it.live("rotates session_id after 30min idle", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "old-session-id", - session_last_active: Date.now() - 31 * 60 * 1000, - }); - return Effect.gen(function* () { - const { sessionId } = yield* resolveIdentity(dir); - expect(sessionId).not.toBe("old-session-id"); - expect(sessionId).toMatch(UUID_PATTERN); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "old-session-id", + session_last_active: now - 31 * 60 * 1000, + }); + const { sessionId } = yield* resolveIdentity(dir); + expect(sessionId).not.toBe("old-session-id"); + expect(sessionId).toMatch(UUID_PATTERN); + }), ); }); it.live("updates session_last_active on every call", () => { - const dir = makeTempDir(); - const before = Date.now(); - writeConfig(dir, { - consent: "granted", - device_id: "existing-device-id", - session_id: "existing-session-id", - session_last_active: Date.now() - 5000, - }); - return Effect.gen(function* () { - yield* resolveIdentity(dir); - const config = readConfig(dir); - expect(config.session_last_active).toBeGreaterThanOrEqual(before); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + const before = yield* Clock.currentTimeMillis; + yield* writeConfig(dir, { + consent: "granted", + device_id: "existing-device-id", + session_id: "existing-session-id", + session_last_active: before - 5000, + }); + yield* resolveIdentity(dir); + const config = yield* readConfig(dir); + expect(config.session_last_active).toBeGreaterThanOrEqual(before); + }), ); }); }); describe("resetIdentity", () => { it.live("rotates the persisted device_id and drops the distinct_id", () => { - const dir = makeTempDir(); - writeConfig(dir, { - consent: "granted", - device_id: "old-device-id", - session_id: "session-id", - session_last_active: Date.now(), - distinct_id: "user-a", - }); - return Effect.gen(function* () { - yield* resetIdentity(dir); - const config = readConfig(dir); - expect(config.distinct_id).toBeUndefined(); - expect(config.device_id).not.toBe("old-device-id"); - expect(config.consent).toBe("granted"); - }).pipe( - Effect.provide(fsLayer), - Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + return withTempDir((dir) => + Effect.gen(function* () { + yield* writeConfig(dir, { + consent: "granted", + device_id: "old-device-id", + session_id: "session-id", + session_last_active: yield* Clock.currentTimeMillis, + distinct_id: "user-a", + }); + yield* resetIdentity(dir); + const config = yield* readConfig(dir); + expect(config.distinct_id).toBeUndefined(); + expect(config.device_id).not.toBe("old-device-id"); + expect(config.consent).toBe("granted"); + }), ); }); }); diff --git a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts index a8a80cea61..064938d2d1 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts @@ -1,4 +1,4 @@ -import { createServer, type Server, type Socket } from "node:net"; +import { Effect } from "effect"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { runSupabase } from "../../../tests/helpers/cli.ts"; @@ -8,36 +8,30 @@ import { runSupabase } from "../../../tests/helpers/cli.ts"; // pending sockets keep the runtime alive, so only actual process exit proves // the telemetry exit cap holds end to end. describe("telemetry against a blackholed PostHog endpoint", () => { - let server: Server; + let server: { readonly port: number; readonly stop: () => Promise<void> }; let host: string; let connections = 0; - const sockets = new Set<Socket>(); - beforeAll(async () => { - server = createServer((socket) => { - connections += 1; - sockets.add(socket); - // Aborted requests reset the connection; without a listener the - // server-side ECONNRESET becomes an uncaught exception. - socket.on("error", () => {}); - socket.on("close", () => sockets.delete(socket)); + beforeAll(() => { + const runningServer = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => { + connections += 1; + return Effect.runPromise(Effect.never); + }, }); - await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Failed to allocate a blackhole port"); - } - host = `http://127.0.0.1:${address.port}`; + const { port } = runningServer; + if (port === undefined) throw new Error("Failed to allocate a blackhole port"); + server = { port, stop: () => runningServer.stop(true) }; + host = `http://127.0.0.1:${port}`; }); - afterAll(async () => { - for (const socket of sockets) socket.destroy(); - await new Promise<void>((resolve) => server.close(() => resolve())); - }); + afterAll(() => server.stop()); - test("commands exit promptly, cleanly, and quietly", async () => { + test("commands exit promptly, cleanly, and quietly", () => { const startedAt = performance.now(); - const { stdout, stderr, exitCode } = await runSupabase(["telemetry", "status"], { + return runSupabase(["telemetry", "status"], { entrypoint: "legacy", env: { // spawnSupabase disables telemetry for every test by default; this @@ -47,17 +41,18 @@ describe("telemetry against a blackholed PostHog endpoint", () => { SUPABASE_TELEMETRY_POSTHOG_KEY: "phc_e2e_blackhole_test", SUPABASE_TELEMETRY_POSTHOG_HOST: host, }, - }); - const elapsedMs = performance.now() - startedAt; + }).then(({ stdout, stderr, exitCode }) => { + const elapsedMs = performance.now() - startedAt; - expect(exitCode).toBe(0); - expect(stdout).toContain("Telemetry is enabled."); - expect(stderr).toBe(""); - // Telemetry must have actually reached the blackhole, otherwise the - // timing assertion below passes vacuously with telemetry off. - expect(connections).toBeGreaterThanOrEqual(1); - // Healthy runs measure ~2.5s (2s drain cap + spawn overhead); the nearest - // real failure signature is the SDK's 5s default deadline plus startup. - expect(elapsedMs).toBeLessThan(4_500); + expect(exitCode).toBe(0); + expect(stdout).toContain("Telemetry is enabled."); + expect(stderr).toBe(""); + // Telemetry must have actually reached the blackhole, otherwise the + // timing assertion below passes vacuously with telemetry off. + expect(connections).toBeGreaterThanOrEqual(1); + // Healthy runs measure ~2.5s (2s drain cap + spawn overhead); the nearest + // real failure signature is the SDK's 5s default deadline plus startup. + expect(elapsedMs).toBeLessThan(4_500); + }); }); }); diff --git a/apps/cli/src/shared/telemetry/posthog-client.ts b/apps/cli/src/shared/telemetry/posthog-client.ts index 28d187ed01..d45a3e244f 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.ts @@ -1,5 +1,11 @@ -import { Effect } from "effect"; +import { Data, Effect } from "effect"; +import type * as Scope from "effect/Scope"; import { PostHog, type PostHogOptions } from "posthog-node"; +import { + actionability, + ErrorActionabilityId, + type CliErrorActionabilityDeclaration, +} from "./error-actionability.ts"; const EXIT_DELAY_CAP_MS = 2_000; @@ -9,18 +15,46 @@ const delivered = { json: () => Promise.resolve({}), }; +class PosthogFetchError extends Data.TaggedError("PosthogFetchError")<{ + readonly cause: unknown; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +type FetchImplementation = (url: string | URL, options: RequestInit) => Promise<Response>; + // posthog-node has no logger hook: delivery failures hit hardcoded // console.error calls and multi-second retries, so report them as delivered. -export const fireAndForgetFetch: NonNullable<PostHogOptions["fetch"]> = async (url, options) => { - try { - const response = await globalThis.fetch(url, options); - return response.status >= 400 ? delivered : response; - } catch { - return delivered; - } -}; +export const makeFireAndForgetFetch = + (fetch: FetchImplementation): NonNullable<PostHogOptions["fetch"]> => + (url, options) => + Effect.runPromise( + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: () => fetch(url, options), + catch: (cause) => new PosthogFetchError({ cause }), + }).pipe(Effect.orElseSucceed(() => delivered)); + if (response === delivered || response.status >= 400) { + return delivered; + } + return response; + }), + ); -export const scopedPosthogClient = (apiKey: string, host: string) => +export const scopedPosthogClient: ( + apiKey: string, + host: string, + fetch?: FetchImplementation, +) => Effect.Effect<PostHog, never, Scope.Scope> = ( + apiKey: string, + host: string, + // The PostHog SDK owns this outer Promise-based platform boundary; callers may + // still inject a fetch implementation for deterministic tests. + // oxlint-disable-next-line effecttsgo/global-fetch -- native fetch is the explicit host boundary for telemetry delivery. + fetch: FetchImplementation = globalThis.fetch, +) => Effect.acquireRelease( Effect.sync(() => { const shutdown = new AbortController(); @@ -30,7 +64,7 @@ export const scopedPosthogClient = (apiKey: string, host: string) => flushInterval: 0, requestTimeout: EXIT_DELAY_CAP_MS, fetch: (url, options) => - fireAndForgetFetch(url, { + makeFireAndForgetFetch(fetch)(url, { ...options, signal: options.signal ? AbortSignal.any([options.signal, shutdown.signal]) diff --git a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts index 0f43315014..ee8defa6db 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -1,56 +1,94 @@ import { describe, expect, it } from "@effect/vitest"; -import { afterEach, vi } from "vitest"; -import { Effect } from "effect"; +import { Data, Deferred, Effect } from "effect"; import { PostHog } from "posthog-node"; -import { fireAndForgetFetch, scopedPosthogClient } from "./posthog-client.ts"; +import { makeFireAndForgetFetch, scopedPosthogClient } from "./posthog-client.ts"; const BATCH_URL = "https://eu.i.posthog.com/batch/"; const BATCH_OPTIONS = { method: "POST" as const, headers: {}, body: "{}" }; -describe("fireAndForgetFetch", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); +class PosthogTestError extends Data.TaggedError("PosthogTestError")<{ + readonly message: string; +}> {} + +const makeBlackholeFetch = + ( + requestStarted: Deferred.Deferred<void>, + requestAborted: Deferred.Deferred<void>, + activeRequests: { value: number }, + ) => + (_url: string | URL, options: RequestInit) => + Effect.runPromise( + Effect.callback<Response, PosthogTestError>((resume, signal) => { + activeRequests.value += 1; + Effect.runSync(Deferred.succeed(requestStarted, undefined)); + let completed = false; + const abort = () => { + if (completed) return; + completed = true; + activeRequests.value -= 1; + Effect.runSync(Deferred.succeed(requestAborted, undefined)); + resume(Effect.fail(new PosthogTestError({ message: "The operation was aborted." }))); + }; + if (options.signal?.aborted) { + abort(); + } else { + options.signal?.addEventListener("abort", abort); + signal.addEventListener("abort", abort); + } + return Effect.sync(() => { + options.signal?.removeEventListener("abort", abort); + signal.removeEventListener("abort", abort); + }); + }), + ); - it("passes successful responses through untouched", async () => { - vi.stubGlobal("fetch", async () => new Response(`{"status":1}`, { status: 200 })); +describe("fireAndForgetFetch", () => { + it("passes successful responses through untouched", () => + Effect.runPromise( + Effect.gen(function* () { + const fetch = () => Promise.resolve(new Response(`{"status":1}`, { status: 200 })); - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); + const response = yield* Effect.tryPromise(() => + makeFireAndForgetFetch(fetch)(BATCH_URL, BATCH_OPTIONS), + ); - expect(response.status).toBe(200); - expect(await response.text()).toBe(`{"status":1}`); - }); + expect(response.status).toBe(200); + expect(yield* Effect.tryPromise(() => response.text())).toBe(`{"status":1}`); + }), + )); - it("reports success when the network is unreachable", async () => { - vi.stubGlobal("fetch", async () => { - throw new Error("connect ECONNREFUSED"); - }); + it("reports success when the network is unreachable", () => + Effect.runPromise( + Effect.gen(function* () { + const fetch = () => Promise.reject(new Error("connect ECONNREFUSED")); - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); + const response = yield* Effect.tryPromise(() => + makeFireAndForgetFetch(fetch)(BATCH_URL, BATCH_OPTIONS), + ); - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - expect(await response.json()).toEqual({}); - }); + expect(response.status).toBe(200); + expect(yield* Effect.tryPromise(() => response.text())).toBe(""); + expect(yield* Effect.tryPromise(() => response.json())).toEqual({}); + }), + )); - it("reports success on error responses so the SDK never retries or logs", async () => { - vi.stubGlobal( - "fetch", - async () => new Response("Proxy Authentication Required", { status: 407 }), - ); + it("reports success on error responses so the SDK never retries or logs", () => + Effect.runPromise( + Effect.gen(function* () { + const fetch = () => + Promise.resolve(new Response("Proxy Authentication Required", { status: 407 })); - const response = await fireAndForgetFetch(BATCH_URL, BATCH_OPTIONS); + const response = yield* Effect.tryPromise(() => + makeFireAndForgetFetch(fetch)(BATCH_URL, BATCH_OPTIONS), + ); - expect(response.status).toBe(200); - expect(await response.text()).toBe(""); - }); + expect(response.status).toBe(200); + expect(yield* Effect.tryPromise(() => response.text())).toBe(""); + }), + )); }); describe("scopedPosthogClient", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - it.live("captures and shuts down cleanly against an unreachable host", () => Effect.gen(function* () { const client = yield* scopedPosthogClient("phc_test", "http://127.0.0.1:9"); @@ -63,44 +101,43 @@ describe("scopedPosthogClient", () => { "bounds the whole shutdown when a request is in flight and another event is queued", () => Effect.gen(function* () { - let requestStarted = () => {}; - const firstRequestInFlight = new Promise<void>((resolve) => { - requestStarted = resolve; - }); - let activeRequests = 0; - vi.stubGlobal( - "fetch", - (_url: string, options: { signal?: AbortSignal }) => - new Promise<Response>((_resolve, reject) => { - activeRequests += 1; - requestStarted(); - const abort = () => { - activeRequests -= 1; - reject(new DOMException("The operation was aborted.", "AbortError")); - }; - if (options.signal?.aborted) { - abort(); - return; - } - options.signal?.addEventListener("abort", abort); - }), - ); + const firstRequestInFlight: Deferred.Deferred<void, never> = yield* Deferred.make< + void, + never + >(); + const firstRequestAborted = yield* Deferred.make<void>(); + const activeRequests = { value: 0 }; + const fetch = makeBlackholeFetch(firstRequestInFlight, firstRequestAborted, activeRequests); const startedAt = performance.now(); - yield* Effect.gen(function* () { - const client = yield* scopedPosthogClient("phc_test", "https://blackhole.invalid"); - client.capture({ event: "first_event", distinctId: "device-1" }); - yield* Effect.promise(() => firstRequestInFlight); - client.capture({ event: "second_event", distinctId: "device-1" }); - }).pipe(Effect.scoped); + const clientProgram = scopedPosthogClient("phc_test", "https://blackhole.invalid", fetch); + const runClient = Effect.scoped( + clientProgram.pipe( + Effect.tap((client) => + Effect.sync(() => { + client.capture({ event: "first_event", distinctId: "device-1" }); + }), + ), + Effect.flatMap((client) => + Deferred.await(firstRequestInFlight).pipe(Effect.as(client)), + ), + Effect.tap((client) => + Effect.sync(() => { + client.capture({ event: "second_event", distinctId: "device-1" }); + }), + ), + Effect.asVoid, + ), + ); + yield* runClient; expect(performance.now() - startedAt).toBeLessThan(3_000); - // The SDK's drain keeps running past the shutdown deadline; without - // cancellation it starts the queued request AFTER scope release and - // keeps the process alive for that request's own timeout. - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(activeRequests).toBe(0); + // Scope release must not return until its owned in-flight request has + // observed cancellation. Any queued request inherits the already- + // aborted shutdown signal and therefore cannot stay active either. + yield* Deferred.await(firstRequestAborted); + expect(activeRequests.value).toBe(0); }), 10_000, ); diff --git a/apps/cli/src/shared/telemetry/posthog-config.ts b/apps/cli/src/shared/telemetry/posthog-config.ts index a37365d69e..4ada702c17 100644 --- a/apps/cli/src/shared/telemetry/posthog-config.ts +++ b/apps/cli/src/shared/telemetry/posthog-config.ts @@ -20,12 +20,16 @@ function readNonEmptyEnv( return nonEmptyString(env[key]); } -function shippedPosthogHost(): Option.Option<string> { - return nonEmptyString(process.env.SUPABASE_CLI_POSTHOG_HOST); +function shippedPosthogHost( + env: Readonly<Record<string, string | undefined>>, +): Option.Option<string> { + return nonEmptyString(env.SUPABASE_CLI_POSTHOG_HOST); } -function shippedPosthogKey(): Option.Option<string> { - return nonEmptyString(process.env.SUPABASE_CLI_POSTHOG_KEY); +function shippedPosthogKey( + env: Readonly<Record<string, string | undefined>>, +): Option.Option<string> { + return nonEmptyString(env.SUPABASE_CLI_POSTHOG_KEY); } export function resolvePosthogConfig( @@ -33,11 +37,11 @@ export function resolvePosthogConfig( ): PosthogConfig { return { host: readNonEmptyEnv(env, "SUPABASE_TELEMETRY_POSTHOG_HOST").pipe( - Option.orElse(shippedPosthogHost), + Option.orElse(() => shippedPosthogHost(env)), Option.getOrElse(() => DEFAULT_HOST), ), key: readNonEmptyEnv(env, "SUPABASE_TELEMETRY_POSTHOG_KEY").pipe( - Option.orElse(shippedPosthogKey), + Option.orElse(() => shippedPosthogKey(env)), ), }; } diff --git a/apps/cli/src/shared/telemetry/posthog-config.unit.test.ts b/apps/cli/src/shared/telemetry/posthog-config.unit.test.ts index 8428d27c59..70913accbc 100644 --- a/apps/cli/src/shared/telemetry/posthog-config.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-config.unit.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Option } from "effect"; -import { processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { resolvePosthogConfig } from "./posthog-config.ts"; describe("resolvePosthogConfig", () => { @@ -10,23 +9,19 @@ describe("resolvePosthogConfig", () => { expect(config.host).toBe("https://eu.i.posthog.com"); expect(Option.isNone(config.key)).toBe(true); - }).pipe(Effect.provide(processEnvLayer())), + }), ); it.live("uses the build-injected key and host by default", () => Effect.sync(() => { - const config = resolvePosthogConfig({}); + const config = resolvePosthogConfig({ + SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", + SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", + }); expect(config.host).toBe("https://build-posthog.example"); expect(config.key).toEqual(Option.some("phc_build_key")); - }).pipe( - Effect.provide( - processEnvLayer({ - SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", - SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", - }), - ), - ), + }), ); it.live("prefers runtime overrides over build-injected values", () => @@ -38,13 +33,6 @@ describe("resolvePosthogConfig", () => { expect(config.host).toBe("https://runtime-posthog.example"); expect(config.key).toEqual(Option.some("phc_runtime_key")); - }).pipe( - Effect.provide( - processEnvLayer({ - SUPABASE_CLI_POSTHOG_HOST: "https://build-posthog.example", - SUPABASE_CLI_POSTHOG_KEY: "phc_build_key", - }), - ), - ), + }), ); }); diff --git a/apps/cli/src/shared/telemetry/runtime.layer.ts b/apps/cli/src/shared/telemetry/runtime.layer.ts index 611ae867e5..be4ede08f8 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.ts @@ -1,5 +1,5 @@ import { note } from "@clack/prompts"; -import { Effect, Layer, Option, Path } from "effect"; +import { Config, Crypto, Effect, Layer, Option, Path } from "effect"; import { CliConfig } from "../../next/config/cli-config.service.ts"; import { CLI_VERSION } from "../cli/version.ts"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; @@ -11,23 +11,25 @@ import { TelemetryRuntime } from "./runtime.service.ts"; const CI_ENV_VARS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "JENKINS_URL", "BUILDKITE"]; -function identityFromConfig(config: Option.Option<TelemetryConfig>) { - if (Option.isSome(config)) { +const identityFromConfig = (config: Option.Option<TelemetryConfig>) => + Effect.gen(function* () { + if (Option.isSome(config)) { + return { + deviceId: config.value.device_id, + sessionId: config.value.session_id, + distinctId: config.value.distinct_id, + isFirstRun: false, + } as const; + } + + const crypto = yield* Crypto.Crypto; return { - deviceId: config.value.device_id, - sessionId: config.value.session_id, - distinctId: config.value.distinct_id, + deviceId: yield* crypto.randomUUIDv4, + sessionId: yield* crypto.randomUUIDv4, + distinctId: undefined, isFirstRun: false, } as const; - } - - return { - deviceId: crypto.randomUUID(), - sessionId: crypto.randomUUID(), - distinctId: undefined, - isFirstRun: false, - } as const; -} + }); export const telemetryRuntimeLayer = Layer.effect( TelemetryRuntime, @@ -55,20 +57,17 @@ export const telemetryRuntimeLayer = Layer.effect( } identity = yield* resolveIdentity(configDir); } else { - identity = identityFromConfig(config); + identity = yield* identityFromConfig(config); } const showDebug = (Option.isSome(cliConfig.debug) && cliConfig.debug.value === "1") || (Option.isSome(cliConfig.telemetryDebug) && cliConfig.telemetryDebug.value === "1"); - let isCi = false; - for (const envVar of CI_ENV_VARS) { - if (process.env[envVar] !== undefined) { - isCi = true; - break; - } - } + const ciValues = yield* Effect.all( + CI_ENV_VARS.map((envVar) => Config.option(Config.string(envVar))), + ); + const isCi = ciValues.some(Option.isSome); return TelemetryRuntime.of({ configDir, diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index a81bc3c044..b93cc5ffb5 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { Effect, Layer } from "effect"; +import { Data, Effect, FileSystem, Layer, Path, Schema } from "effect"; import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; import { TelemetryRuntime } from "./runtime.service.ts"; import { telemetryRuntimeLayer } from "./runtime.layer.ts"; @@ -14,25 +11,69 @@ import { processEnvLayer, } from "../../../tests/helpers/mocks.ts"; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-runtime-test-")); -} +const fsLayer = BunServices.layer; + +class RuntimeTestSchemaError extends Data.TaggedError("RuntimeTestSchemaError")<{ + readonly cause: unknown; +}> {} + +const makeTempDir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "supabase-runtime-test-" }); +}); + +const pathJoin = (...parts: ReadonlyArray<string>) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(...parts); + }); + +const pathExists = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(pathname); + }); + +const writeText = (pathname: string, content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(pathname), { recursive: true }); + yield* fs.writeFileString(pathname, content); + }); + +const removePath = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(pathname, { recursive: true, force: true }); + }).pipe(Effect.ignore); + +const encodeJson = (value: unknown) => + Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( + Effect.mapError((cause) => new RuntimeTestSchemaError({ cause })), + ); + +const withHomeDir = <A, E, R>(use: (homeDir: string) => Effect.Effect<A, E, R>) => + Effect.gen(function* () { + const homeDir = yield* makeTempDir; + return yield* use(homeDir).pipe(Effect.ensuring(removePath(homeDir))); + }).pipe(Effect.provide(fsLayer)); function buildLayer(opts: { homeDir: string; env?: Record<string, string>; stdoutIsTty?: boolean; -}): Layer.Layer<TelemetryRuntime> { +}) { const runtimeInfoLayer = mockRuntimeInfo({ homeDir: opts.homeDir }); const projectContextLayer = mockProjectContext(); const envLayer = processEnvLayer({ - SUPABASE_HOME: opts.homeDir, ...opts.env, }); const ttyLayer = mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false }); const configLayer = cliConfigLayer.pipe( Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer), + Layer.provide(envLayer), ); const telemetryLayer = telemetryRuntimeLayer.pipe( Layer.provide(configLayer), @@ -41,102 +82,86 @@ function buildLayer(opts: { Layer.provide(BunServices.layer), ); - return Layer.mergeAll(envLayer, telemetryLayer); + return telemetryLayer; } describe("telemetryRuntimeLayer", () => { it.live("does not create telemetry.json when telemetry is disabled by env on first run", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("denied"); - expect(runtime.isFirstRun).toBe(false); - expect(existsSync(configPath)).toBe(false); - }).pipe( - Effect.provide( - buildLayer({ - homeDir, - env: { SUPABASE_TELEMETRY_DISABLED: "1" }, - }), - ), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + return withHomeDir((homeDir) => + Effect.gen(function* () { + const configPath = yield* pathJoin(homeDir, ".supabase", "telemetry.json"); + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.isFirstRun).toBe(false); + expect(yield* pathExists(configPath)).toBe(false); + }).pipe(Effect.provide(buildLayer({ homeDir, env: { SUPABASE_TELEMETRY_DISABLED: "1" } }))), ); }); it.live("marks the actual first granted invocation as first run", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + return withHomeDir((homeDir) => + Effect.gen(function* () { + const configPath = yield* pathJoin(homeDir, ".supabase", "telemetry.json"); + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* pathExists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))), ); }); it.live("treats a malformed telemetry.json as a fresh first run instead of crashing", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync(configPath, ""); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + return withHomeDir((homeDir) => + Effect.gen(function* () { + const configPath = yield* pathJoin(homeDir, ".supabase", "telemetry.json"); + yield* writeText(configPath, ""); + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* pathExists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))), ); }); it.live("silently ignores structurally invalid telemetry.json instead of crashing", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync(configPath, JSON.stringify({ consent: "granted" })); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - expect(runtime.isFirstRun).toBe(true); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + return withHomeDir((homeDir) => + Effect.gen(function* () { + const configPath = yield* pathJoin(homeDir, ".supabase", "telemetry.json"); + yield* writeText(configPath, yield* encodeJson({ consent: "granted" })); + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("granted"); + expect(runtime.isFirstRun).toBe(true); + expect(yield* pathExists(configPath)).toBe(true); + }).pipe(Effect.provide(buildLayer({ homeDir }))), ); }); it.live("honors a legacy disabled telemetry state", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync( - configPath, - JSON.stringify({ - enabled: false, - device_id: "legacy-device", - session_id: "legacy-session", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, + return withHomeDir((homeDir) => + Effect.gen(function* () { + const configPath = yield* pathJoin(homeDir, ".supabase", "telemetry.json"); + yield* writeText( + configPath, + yield* encodeJson({ + enabled: false, + device_id: "legacy-device", + session_id: "legacy-session", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + const runtime = yield* Effect.gen(function* () { + const runtime = yield* TelemetryRuntime; + expect(runtime.consent).toBe("denied"); + expect(runtime.deviceId).toBe("legacy-device"); + expect(runtime.sessionId).toBe("legacy-session"); + expect(runtime.isFirstRun).toBe(false); + return runtime; + }).pipe(Effect.provide(buildLayer({ homeDir, stdoutIsTty: true }))); + expect(runtime.consent).toBe("denied"); + expect(yield* pathExists(configPath)).toBe(true); }), ); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("denied"); - expect(runtime.deviceId).toBe("legacy-device"); - expect(runtime.sessionId).toBe("legacy-session"); - expect(runtime.isFirstRun).toBe(false); - expect(existsSync(configPath)).toBe(true); - }).pipe( - Effect.provide(buildLayer({ homeDir, stdoutIsTty: true })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), - ); }); // CLI-1868 (telemetry enable/disable firing cli_command_executed on pre-toggle @@ -145,43 +170,38 @@ describe("telemetryRuntimeLayer", () => { // mirroring Go's PersistentPreRunE snapshot, which a command's own RunE // (e.g. `telemetry disable`'s SetEnabled) cannot retroactively change. it.live("captures consent once; a later on-disk write does not change it", () => { - const homeDir = makeTempDir(); - const configPath = path.join(homeDir, "telemetry.json"); - writeFileSync( - configPath, - JSON.stringify({ - enabled: true, - device_id: "device-123", - session_id: "session-123", - session_last_active: "2026-04-01T12:00:00Z", - schema_version: 1, - }), - ); - - return Effect.gen(function* () { - const runtime = yield* TelemetryRuntime; - expect(runtime.consent).toBe("granted"); - - // Simulates `disable`'s handler rewriting the file mid-command, after - // this layer already resolved `consent` — the already-built runtime - // must keep reporting the pre-toggle value. - yield* Effect.sync(() => - writeFileSync( + return withHomeDir((homeDir) => + Effect.gen(function* () { + const configPath = yield* pathJoin(homeDir, ".supabase", "telemetry.json"); + yield* writeText( + configPath, + yield* encodeJson({ + enabled: true, + device_id: "device-123", + session_id: "session-123", + session_last_active: "2026-04-01T12:00:00Z", + schema_version: 1, + }), + ); + const runtime = yield* TelemetryRuntime.pipe(Effect.provide(buildLayer({ homeDir }))); + expect(runtime.consent).toBe("granted"); + + // Simulates `disable`'s handler rewriting the file mid-command, after + // this layer already resolved `consent` — the already-built runtime + // must keep reporting the pre-toggle value. + yield* writeText( configPath, - JSON.stringify({ + yield* encodeJson({ enabled: false, device_id: "device-123", session_id: "session-123", session_last_active: "2026-04-01T12:00:00Z", schema_version: 1, }), - ), - ); + ); - expect(runtime.consent).toBe("granted"); - }).pipe( - Effect.provide(buildLayer({ homeDir })), - Effect.ensuring(Effect.sync(() => rmSync(homeDir, { recursive: true, force: true }))), + expect(runtime.consent).toBe("granted"); + }), ); }); }); diff --git a/apps/cli/src/shared/telemetry/tracing.layer.ts b/apps/cli/src/shared/telemetry/tracing.layer.ts index 770a2218ee..f6798bc3be 100644 --- a/apps/cli/src/shared/telemetry/tracing.layer.ts +++ b/apps/cli/src/shared/telemetry/tracing.layer.ts @@ -1,4 +1,16 @@ -import { Effect, Layer, Option, Stdio, Stream, Tracer } from "effect"; +import { + Crypto, + Effect, + FiberSet, + FileSystem, + Layer, + Option, + Path, + Scope, + Stdio, + Stream, + Tracer, +} from "effect"; import type { Exit, Context } from "effect"; import { makeDebugConsoleExporter } from "./exporters/debug-console.ts"; @@ -13,11 +25,11 @@ import { Tracing } from "./tracing.service.ts"; * This layer owns telemetry bootstrap, consent evaluation, identifier loading, * and exporter wiring. Commands only depend on the `Tracing` service tag. */ -function generateHexId(length: number): string { +function generateHexId(length: number, nextInt: () => number): string { const chars = "0123456789abcdef"; let result = ""; for (let i = 0; i < length; i++) { - result += chars[Math.floor(Math.random() * chars.length)]; + result += chars[Math.abs(nextInt()) % chars.length]; } return result; } @@ -49,6 +61,7 @@ class ExportableSpan implements Tracer.Span { readonly sampled: boolean; }, onEnd: (span: ExportableSpan) => void, + nextInt: () => number, ) { this.name = options.name; this.parent = options.parent; @@ -58,10 +71,10 @@ class ExportableSpan implements Tracer.Span { this.sampled = options.sampled; this.status = { _tag: "Started", startTime: options.startTime }; this.traceId = Option.match(options.parent, { - onNone: () => generateHexId(32), + onNone: () => generateHexId(32, nextInt), onSome: (parent) => parent.traceId, }); - this.spanId = generateHexId(16); + this.spanId = generateHexId(16, nextInt); this.onEnd = onEnd; } @@ -85,51 +98,69 @@ class ExportableSpan implements Tracer.Span { addLinks(_links: ReadonlyArray<Tracer.SpanLink>): void {} } -export const tracingLayer = Layer.effect( - Tracing, +export const tracingLayer = Layer.unwrap( Effect.gen(function* () { - const stdio = yield* Stdio.Stdio; - const telemetryRuntime = yield* TelemetryRuntime; - const exportSpanToDebugConsole = makeDebugConsoleExporter((line) => { - Effect.runFork(Stream.make(line).pipe(Stream.run(stdio.stderr()), Effect.ignore)); - }); + const scope = yield* Scope.Scope; + return Layer.effect( + Tracing, + Effect.gen(function* () { + const stdio = yield* Stdio.Stdio; + const telemetryRuntime = yield* TelemetryRuntime; + const crypto = yield* Crypto.Crypto; + const exportFibers = yield* FiberSet.make<void, never>().pipe( + Effect.provideService(Scope.Scope, scope), + ); + const runExport = yield* FiberSet.runtime(exportFibers)< + FileSystem.FileSystem | Path.Path + >(); + yield* Scope.addFinalizer(scope, FiberSet.awaitEmpty(exportFibers)); + const context = yield* Effect.context< + Stdio.Stdio | FileSystem.FileSystem | Path.Path | Crypto.Crypto + >(); + const exportSpanToDebugConsole = makeDebugConsoleExporter((line) => { + Effect.runForkWith(context)( + Stream.make(line).pipe(Stream.run(stdio.stderr()), Effect.ignore), + ); + }); - // Exporters are gated by consent/debug flags before spans start flowing. - if (telemetryRuntime.consent === "granted") { - yield* initNdjsonExporter(telemetryRuntime.tracesDir); - } + // Exporters are gated by consent/debug flags before spans start flowing. + if (telemetryRuntime.consent === "granted") { + yield* initNdjsonExporter(telemetryRuntime.tracesDir); + } - function onSpanEnd(span: ExportableSpan): void { - if (!span.sampled) return; - if (telemetryRuntime.consent === "granted") { - exportSpanToNdjson(span, telemetryRuntime.tracesDir); - } - if (telemetryRuntime.showDebug) { - exportSpanToDebugConsole(span); - } - } + function onSpanEnd(span: ExportableSpan): void { + if (!span.sampled) return; + if (telemetryRuntime.consent === "granted") { + runExport(exportSpanToNdjson(span, telemetryRuntime.tracesDir)); + } + if (telemetryRuntime.showDebug) { + exportSpanToDebugConsole(span); + } + } - // Global attributes are attached once here so individual commands stay lean. - const globalAttrs: Record<string, unknown> = { - schema_version: 1, - device_id: telemetryRuntime.deviceId, - session_id: telemetryRuntime.sessionId, - is_first_run: telemetryRuntime.isFirstRun, - is_tty: telemetryRuntime.isTty, - is_ci: telemetryRuntime.isCi, - os: telemetryRuntime.os, - arch: telemetryRuntime.arch, - cli_version: telemetryRuntime.cliVersion, - }; + // Global attributes are attached once here so individual commands stay lean. + const globalAttrs: Record<string, unknown> = { + schema_version: 1, + device_id: telemetryRuntime.deviceId, + session_id: telemetryRuntime.sessionId, + is_first_run: telemetryRuntime.isFirstRun, + is_tty: telemetryRuntime.isTty, + is_ci: telemetryRuntime.isCi, + os: telemetryRuntime.os, + arch: telemetryRuntime.arch, + cli_version: telemetryRuntime.cliVersion, + }; - return Tracer.make({ - span(options) { - const span = new ExportableSpan(options, onSpanEnd); - for (const [key, value] of Object.entries(globalAttrs)) { - span.attribute(key, value); - } - return span; - }, - }); + return Tracer.make({ + span(options) { + const span = new ExportableSpan(options, onSpanEnd, () => crypto.nextIntUnsafe()); + for (const [key, value] of Object.entries(globalAttrs)) { + span.attribute(key, value); + } + return span; + }, + }); + }), + ).pipe(Layer.provide(telemetryRuntimeLayer)); }), -).pipe(Layer.provide(telemetryRuntimeLayer)); +); diff --git a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts index d6642f8492..16bef1fe38 100644 --- a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts @@ -1,26 +1,23 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; -import { Effect, Exit, Layer, Option, Context, Tracer } from "effect"; -import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; -import type { TelemetryConfig } from "./types.ts"; import { - mockProjectContext, - mockRuntimeInfo, - mockTty, - processEnvLayer, -} from "../../../tests/helpers/mocks.ts"; + Clock, + ConfigProvider, + Context, + Deferred, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, + Tracer, +} from "effect"; +import { cliConfigLayer } from "../../next/config/cli-config.layer.ts"; +import { TelemetryConfigSchema, type TelemetryConfig } from "./types.ts"; +import { mockProjectContext, mockRuntimeInfo, mockTty } from "../../../tests/helpers/mocks.ts"; import { tracingLayer } from "./tracing.layer.ts"; // --------------------------------------------------------------------------- @@ -29,20 +26,71 @@ import { tracingLayer } from "./tracing.layer.ts"; const fsLayer = BunServices.layer; -function makeTempDir(): string { - return mkdtempSync(path.join(tmpdir(), "supabase-tracing-test-")); -} +const makeTempDir = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ prefix: "supabase-tracing-test-" }); +}); -function writeConfig(dir: string, config: TelemetryConfig): void { - mkdirSync(dir, { recursive: true }); - writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); -} +const pathJoin = (...parts: ReadonlyArray<string>) => + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(...parts); + }); + +const pathExists = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(pathname); + }); + +const readText = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(pathname); + }); + +const readNames = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readDirectory(pathname); + }); + +const removePath = (pathname: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(pathname, { recursive: true, force: true }); + }).pipe(Effect.ignore); + +const writeConfig = (dir: string, config: TelemetryConfig) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const encoded = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(TelemetryConfigSchema))( + config, + ); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString(path.join(dir, "telemetry.json"), encoded); + }); + +const decodeTelemetryConfig = (content: string) => + Schema.decodeEffect(Schema.fromJsonString(TelemetryConfigSchema))(content); + +const withHome = <A, E, R>(use: (home: string) => Effect.Effect<A, E, R>) => + Effect.gen(function* () { + const home = yield* makeTempDir; + return yield* use(home).pipe(Effect.ensuring(removePath(home))); + }).pipe(Effect.provide(fsLayer)); // --------------------------------------------------------------------------- // Layer builder helpers // --------------------------------------------------------------------------- -function buildLayer(opts: { home: string; env?: Record<string, string>; stdoutIsTty?: boolean }) { +function buildLayer(opts: { + home: string; + env?: Record<string, string>; + stdoutIsTty?: boolean; + fsLayer?: Layer.Layer<FileSystem.FileSystem>; +}) { const env: Record<string, string> = { HOME: opts.home, ...opts.env, @@ -53,17 +101,72 @@ function buildLayer(opts: { home: string; env?: Record<string, string>; stdoutIs platform: "linux", arch: "x64", }); - const projectContextLayer = mockProjectContext(); + const projectContextLayer = mockProjectContext({ + projectEnv: Option.some({ + paths: { + projectRoot: opts.home, + supabaseDir: `${opts.home}/supabase`, + configPath: `${opts.home}/supabase/config.toml`, + envPath: `${opts.home}/supabase/.env`, + envLocalPath: `${opts.home}/supabase/.env.local`, + }, + values: env, + loadedPaths: [], + sources: {}, + }), + }); + const configLayer = cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provideMerge(BunServices.layer), + ); return Layer.mergeAll( - fsLayer, runtimeInfoLayer, projectContextLayer, - processEnvLayer(env), - cliConfigLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer)), + configLayer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env })), mockTty({ stdoutIsTty: opts.stdoutIsTty ?? false, stdinIsTty: false, }), + opts.fsLayer ?? fsLayer, + ); +} + +function gatedFileSystem(opts: { + writeStarted: Deferred.Deferred<void>; + releaseWrite: Deferred.Deferred<void>; + writeFinished: Deferred.Deferred<void>; + setCompletedBeforeScopeClose: (value: boolean) => void; + isScopeClosed: () => boolean; +}) { + return Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const base = yield* FileSystem.FileSystem; + return FileSystem.make({ + ...base, + open: (pathname, options) => + base.open(pathname, options).pipe( + Effect.map((file) => + pathname.endsWith(".ndjson") + ? { + ...file, + writeAll: (data: Uint8Array) => + Effect.gen(function* () { + yield* Deferred.succeed(opts.writeStarted, undefined); + yield* Deferred.await(opts.releaseWrite); + yield* Effect.yieldNow; + yield* file.writeAll(data); + opts.setCompletedBeforeScopeClose(!opts.isScopeClosed()); + yield* Deferred.succeed(opts.writeFinished, undefined); + }), + } + : file, + ), + ), + }); + }).pipe(Effect.provide(BunServices.layer)), ); } @@ -71,6 +174,7 @@ function buildTracingLayer(opts: { home: string; env?: Record<string, string>; stdoutIsTty?: boolean; + fsLayer?: Layer.Layer<FileSystem.FileSystem>; }) { return tracingLayer.pipe(Layer.provide(buildLayer(opts))); } @@ -79,24 +183,28 @@ function buildTracingLayer(opts: { // Span factory helper (mirrors ExportableSpan constructor options) // --------------------------------------------------------------------------- -function makeSpanOptions( +const makeSpanOptions = ( overrides: Partial<{ name: string; sampled: boolean; parent: Option.Option<Tracer.AnySpan>; }> = {}, -) { - return { +) => + Effect.map(Clock.currentTimeMillis, (now) => ({ name: overrides.name ?? "test-span", parent: overrides.parent ?? Option.none(), annotations: Context.empty(), links: [] as Tracer.SpanLink[], - startTime: BigInt(Date.now()) * 1_000_000n, + startTime: BigInt(now) * 1_000_000n, kind: "internal" as Tracer.SpanKind, root: false, sampled: overrides.sampled ?? true, - }; -} + })); + +const endSpan = (span: Tracer.Span, offsetMs: number) => + Effect.map(Clock.currentTimeMillis, (now) => { + span.end(BigInt(now + offsetMs) * 1_000_000n, Exit.void); + }); // --------------------------------------------------------------------------- // Layer construction & first-run @@ -104,93 +212,75 @@ function makeSpanOptions( describe("tracingLayer – layer construction & first-run", () => { it.live("first-run TTY: creates telemetry.json with consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - return Effect.gen(function* () { - yield* Effect.void; - }).pipe( - Effect.provide(buildTracingLayer({ home, stdoutIsTty: true })), - Effect.ensuring( - Effect.sync(() => { - const configPath = path.join(configDir, "telemetry.json"); - expect(existsSync(configPath)).toBe(true); - const config: TelemetryConfig = JSON.parse(readFileSync(configPath, "utf8")); - expect(config.consent).toBe("granted"); - expect(typeof config.device_id).toBe("string"); - expect(config.device_id.length).toBeGreaterThan(0); - expect(typeof config.session_id).toBe("string"); - expect(config.session_id.length).toBeGreaterThan(0); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.provide(buildTracingLayer({ home, stdoutIsTty: true }))); + const configPath = yield* pathJoin(home, ".supabase", "telemetry.json"); + expect(yield* pathExists(configPath)).toBe(true); + const config = yield* decodeTelemetryConfig(yield* readText(configPath)); + expect(config.consent).toBe("granted"); + expect(config.device_id.length).toBeGreaterThan(0); + expect(config.session_id.length).toBeGreaterThan(0); + }), ); }); it.live("first-run non-TTY: creates telemetry.json with consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - return Effect.gen(function* () { - yield* Effect.void; - }).pipe( - Effect.provide(buildTracingLayer({ home, stdoutIsTty: false })), - Effect.ensuring( - Effect.sync(() => { - const configPath = path.join(configDir, "telemetry.json"); - expect(existsSync(configPath)).toBe(true); - const config: TelemetryConfig = JSON.parse(readFileSync(configPath, "utf8")); - expect(config.consent).toBe("granted"); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.provide(buildTracingLayer({ home, stdoutIsTty: false }))); + const configPath = yield* pathJoin(home, ".supabase", "telemetry.json"); + expect(yield* pathExists(configPath)).toBe(true); + const config = yield* decodeTelemetryConfig(yield* readText(configPath)); + expect(config.consent).toBe("granted"); + }), ); }); it.live("existing config with consent=granted: layer builds and tracer is usable", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - writeConfig(configDir, { - consent: "granted", - device_id: "existing-device", - session_id: "existing-session", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - expect(span).toBeDefined(); - expect(span.name).toBe("test-span"); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), + return withHome((home) => + Effect.gen(function* () { + yield* writeConfig(yield* pathJoin(home, ".supabase"), { + consent: "granted", + device_id: "existing-device", + session_id: "existing-session", + session_last_active: yield* Clock.currentTimeMillis, + }); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + expect(span).toBeDefined(); + expect(span.name).toBe("test-span"); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + }), ); }); it.live( "SUPABASE_TELEMETRY_DISABLED=1 overrides consent=granted: no NDJSON export on span end", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); - writeConfig(configDir, { - consent: "granted", - device_id: "existing-device", - session_id: "existing-session", - session_last_active: Date.now(), - }); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + const configDir = yield* pathJoin(home, ".supabase"); + yield* writeConfig(configDir, { + consent: "granted", + device_id: "existing-device", + session_id: "existing-session", + session_last_active: yield* Clock.currentTimeMillis, + }); + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + yield* endSpan(span, 100); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), + ); + const tracesDir = yield* pathJoin(configDir, "traces"); + const hasNdjson = + (yield* pathExists(tracesDir)) && + (yield* readNames(tracesDir)).some((f) => f.endsWith(".ndjson")); + expect(hasNdjson).toBe(false); + }), ); }, ); @@ -202,176 +292,180 @@ describe("tracingLayer – layer construction & first-run", () => { describe("tracingLayer – span behaviour", () => { it.live("span creation attaches global attributes", () => { - const home = makeTempDir(); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - expect(span.attributes.get("schema_version")).toBe(1); - expect(typeof span.attributes.get("device_id")).toBe("string"); - expect(typeof span.attributes.get("session_id")).toBe("string"); - expect(typeof span.attributes.get("is_first_run")).toBe("boolean"); - expect(span.attributes.get("is_tty")).toBe(false); - expect(typeof span.attributes.get("is_ci")).toBe("boolean"); - expect(span.attributes.get("os")).toBe("linux"); - expect(span.attributes.get("arch")).toBe("x64"); - expect(span.attributes.get("cli_version")).toBe("0.0.0-dev"); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), + return withHome((home) => + Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + expect(span.attributes.get("schema_version")).toBe(1); + expect(typeof span.attributes.get("device_id")).toBe("string"); + expect(typeof span.attributes.get("session_id")).toBe("string"); + expect(typeof span.attributes.get("is_first_run")).toBe("boolean"); + expect(span.attributes.get("is_tty")).toBe(false); + expect(typeof span.attributes.get("is_ci")).toBe("boolean"); + expect(span.attributes.get("os")).toBe("linux"); + expect(span.attributes.get("arch")).toBe("x64"); + expect(span.attributes.get("cli_version")).toBe("0.0.0-dev"); + }).pipe(Effect.provide(buildTracingLayer({ home }))), ); }); it.live("span end exports to NDJSON file when consent=granted", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(true); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + yield* endSpan(span, 100); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + const tracesDir = yield* pathJoin(home, ".supabase", "traces"); + const hasNdjson = + (yield* pathExists(tracesDir)) && + (yield* readNames(tracesDir)).some((f) => f.endsWith(".ndjson")); + expect(hasNdjson).toBe(true); + }), + ); + }); + + it.live("drains NDJSON exports before the tracing scope closes", () => { + return withHome((home) => + Effect.gen(function* () { + const writeStarted = yield* Deferred.make<void>(); + const releaseWrite = yield* Deferred.make<void>(); + const writeFinished = yield* Deferred.make<void>(); + let scopeClosed = false; + let completedBeforeScopeClose = false; + const fs = gatedFileSystem({ + writeStarted, + releaseWrite, + writeFinished, + isScopeClosed: () => scopeClosed, + setCompletedBeforeScopeClose: (value) => { + completedBeforeScopeClose = value; + }, + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions({ name: "drained-span" })); + yield* endSpan(span, 100); + yield* Deferred.await(writeStarted); + yield* Deferred.succeed(releaseWrite, undefined); + }).pipe(Effect.provide(buildTracingLayer({ home, fsLayer: fs }))), + ); + + scopeClosed = true; + yield* Deferred.await(writeFinished); + expect(completedBeforeScopeClose).toBe(true); + }), ); }); it.live("does not write API keys to trace files", () => { - const home = makeTempDir(); - const tracesDir = path.join(home, ".supabase", "traces"); const secretKey = `sb_secret_${"a".repeat(40)}`; - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.attribute("http.request.header.apikey", secretKey); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - try { - const traceFile = readdirSync(tracesDir).find((file) => file.endsWith(".ndjson")); - expect(traceFile).toBeDefined(); - const trace = readFileSync(path.join(tracesDir, traceFile!), "utf8"); - expect(trace).not.toContain(secretKey); - expect(trace).not.toContain("http.request.header.apikey"); - } finally { - rmSync(home, { recursive: true, force: true }); - } - }), - ), + return withHome((home) => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + span.attribute("http.request.header.apikey", secretKey); + yield* endSpan(span, 100); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + const tracesDir = yield* pathJoin(home, ".supabase", "traces"); + const traceFile = (yield* readNames(tracesDir)).find((file) => file.endsWith(".ndjson")); + expect(traceFile).toBeDefined(); + const trace = yield* readText(yield* pathJoin(tracesDir, traceFile ?? "")); + expect(trace).not.toContain(secretKey); + expect(trace).not.toContain("http.request.header.apikey"); + }), ); }); it.live("span end does NOT export to NDJSON when SUPABASE_TELEMETRY_DISABLED=1", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + yield* endSpan(span, 100); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DISABLED: "1" } })), + ); + const tracesDir = yield* pathJoin(home, ".supabase", "traces"); + const hasNdjson = + (yield* pathExists(tracesDir)) && + (yield* readNames(tracesDir)).some((f) => f.endsWith(".ndjson")); + expect(hasNdjson).toBe(false); + }), ); }); it.live("span end exports to debug console when SUPABASE_DEBUG=1", () => { - const home = makeTempDir(); - const stderrChunks: string[] = []; - const originalWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = vi.fn((chunk: unknown) => { - stderrChunks.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions({ name: "debug-span" })); - span.end(BigInt(Date.now() + 50) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_DEBUG: "1" } })), - Effect.ensuring( - Effect.sync(() => { - process.stderr.write = originalWrite; - const output = stderrChunks.join(""); - expect(output).toContain("debug-span"); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = vi.fn((chunk: unknown) => { + stderrChunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions({ name: "debug-span" })); + yield* endSpan(span, 50); + }).pipe(Effect.provide(buildTracingLayer({ home, env: { SUPABASE_DEBUG: "1" } }))); + process.stderr.write = originalWrite; + expect(stderrChunks.join("")).toContain("debug-span"); + }), ); }); it.live("span end exports to debug console when SUPABASE_TELEMETRY_DEBUG=1", () => { - const home = makeTempDir(); - const stderrChunks: string[] = []; - const originalWrite = process.stderr.write.bind(process.stderr); - process.stderr.write = vi.fn((chunk: unknown) => { - stderrChunks.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions({ name: "telemetry-debug-span" })); - span.end(BigInt(Date.now() + 50) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DEBUG: "1" } })), - Effect.ensuring( - Effect.sync(() => { - process.stderr.write = originalWrite; - const output = stderrChunks.join(""); - expect(output).toContain("telemetry-debug-span"); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + const stderrChunks: string[] = []; + const originalWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = vi.fn((chunk: unknown) => { + stderrChunks.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions({ name: "telemetry-debug-span" })); + yield* endSpan(span, 50); + }).pipe( + Effect.provide(buildTracingLayer({ home, env: { SUPABASE_TELEMETRY_DEBUG: "1" } })), + ); + process.stderr.write = originalWrite; + expect(stderrChunks.join("")).toContain("telemetry-debug-span"); + }), ); }); it.live("span end skips unsampled spans – no NDJSON export", () => { - const home = makeTempDir(); - const configDir = path.join(home, ".supabase"); - const tracesDir = path.join(configDir, "traces"); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions({ sampled: false })); - span.end(BigInt(Date.now() + 100) * 1_000_000n, Exit.void); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring( - Effect.sync(() => { - const hasNdjson = - existsSync(tracesDir) && readdirSync(tracesDir).some((f) => f.endsWith(".ndjson")); - expect(hasNdjson).toBe(false); - rmSync(home, { recursive: true, force: true }); - }), - ), + return withHome((home) => + Effect.gen(function* () { + yield* Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions({ sampled: false })); + yield* endSpan(span, 100); + }).pipe(Effect.provide(buildTracingLayer({ home }))); + const tracesDir = yield* pathJoin(home, ".supabase", "traces"); + const hasNdjson = + (yield* pathExists(tracesDir)) && + (yield* readNames(tracesDir)).some((f) => f.endsWith(".ndjson")); + expect(hasNdjson).toBe(false); + }), ); }); it.live("CI detection via CI env var sets is_ci=true on span", () => { - const home = makeTempDir(); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - expect(span.attributes.get("is_ci")).toBe(true); - }).pipe( - Effect.provide(buildTracingLayer({ home, env: { CI: "true" } })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), + return withHome((home) => + Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + expect(span.attributes.get("is_ci")).toBe(true); + }).pipe(Effect.provide(buildTracingLayer({ home, env: { CI: "true" } }))), ); }); }); @@ -382,43 +476,41 @@ describe("tracingLayer – span behaviour", () => { describe("ExportableSpan unit tests", () => { it.live("child span inherits traceId from parent span", () => { - const home = makeTempDir(); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const parent = tracer.span(makeSpanOptions({ name: "parent" })); - const child = tracer.span(makeSpanOptions({ name: "child", parent: Option.some(parent) })); - expect(child.traceId).toBe(parent.traceId); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), + return withHome((home) => + Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const parent = tracer.span(yield* makeSpanOptions({ name: "parent" })); + const child = tracer.span( + yield* makeSpanOptions({ name: "child", parent: Option.some(parent) }), + ); + expect(child.traceId).toBe(parent.traceId); + }).pipe(Effect.provide(buildTracingLayer({ home }))), ); }); it.live("event() and addLinks() are no-ops that do not throw", () => { - const home = makeTempDir(); - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - span.event("test-event", BigInt(Date.now()) * 1_000_000n, { key: "val" }); - span.addLinks([]); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), + return withHome((home) => + Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + yield* Effect.map(Clock.currentTimeMillis, (now) => { + span.event("test-event", BigInt(now) * 1_000_000n, { key: "val" }); + }); + span.addLinks([]); + }).pipe(Effect.provide(buildTracingLayer({ home }))), ); }); it.live("span without parent generates 32-char hex traceId and 16-char hex spanId", () => { - const home = makeTempDir(); const HEX_32 = /^[0-9a-f]{32}$/; const HEX_16 = /^[0-9a-f]{16}$/; - return Effect.gen(function* () { - const tracer = yield* Tracer.Tracer; - const span = tracer.span(makeSpanOptions()); - expect(span.traceId).toMatch(HEX_32); - expect(span.spanId).toMatch(HEX_16); - }).pipe( - Effect.provide(buildTracingLayer({ home })), - Effect.ensuring(Effect.sync(() => rmSync(home, { recursive: true, force: true }))), + return withHome((home) => + Effect.gen(function* () { + const tracer = yield* Tracer.Tracer; + const span = tracer.span(yield* makeSpanOptions()); + expect(span.traceId).toMatch(HEX_32); + expect(span.spanId).toMatch(HEX_16); + }).pipe(Effect.provide(buildTracingLayer({ home }))), ); }); }); diff --git a/apps/cli/src/shared/telemetry/types.ts b/apps/cli/src/shared/telemetry/types.ts index 44d1823536..e15089edc6 100644 --- a/apps/cli/src/shared/telemetry/types.ts +++ b/apps/cli/src/shared/telemetry/types.ts @@ -7,7 +7,7 @@ export const TelemetryConfigSchema = Schema.Struct({ consent: ConsentStateSchema, device_id: Schema.String, session_id: Schema.String, - session_last_active: Schema.Number, + session_last_active: Schema.Finite, distinct_id: Schema.optionalKey(Schema.String), }); export type TelemetryConfig = Schema.Schema.Type<typeof TelemetryConfigSchema>; diff --git a/apps/cli/tests/e2e-global-setup.ts b/apps/cli/tests/e2e-global-setup.ts index a8a5eceed8..54fc4fb475 100644 --- a/apps/cli/tests/e2e-global-setup.ts +++ b/apps/cli/tests/e2e-global-setup.ts @@ -1,23 +1,35 @@ -import { execSync } from "node:child_process"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Exit, Layer } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import { prefetch } from "@supabase/stack"; -function hasDockerDaemon(): boolean { - try { - execSync("docker info", { stdio: "ignore" }); - return true; - } catch { - return false; - } -} +const hasDockerDaemon = Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const result = yield* spawner + .exitCode(ChildProcess.make("docker", ["info"], { stdout: "ignore", stderr: "ignore" })) + .pipe(Effect.exit); + return Exit.isSuccess(result) && result.value === 0; + }), +); -export default async function globalSetup() { - const dockerAvailable = hasDockerDaemon(); +const prefetchEffect = (mode?: "docker") => + Effect.tryPromise(() => (mode ? prefetch({ mode }) : prefetch())).pipe(Effect.asVoid); - const warmups = [prefetch()]; +const globalSetupEffect = Effect.gen(function* () { + const dockerAvailable = yield* hasDockerDaemon; + const warmups = [prefetchEffect()]; if (dockerAvailable) { - warmups.push(prefetch({ mode: "docker" })); + warmups.push(prefetchEffect("docker")); } - await Promise.all(warmups); + yield* Effect.all(warmups, { concurrency: "unbounded", discard: true }); +}); + +export default function globalSetup() { + return Effect.runPromise( + globalSetupEffect.pipe(Effect.provide(Layer.mergeAll(BunServices.layer))), + ); } diff --git a/apps/cli/tests/e2e-setup.ts b/apps/cli/tests/e2e-setup.ts index 118c0ab7d9..6655c64db7 100644 --- a/apps/cli/tests/e2e-setup.ts +++ b/apps/cli/tests/e2e-setup.ts @@ -1,6 +1,4 @@ import { afterEach } from "vitest"; import { cleanupRegisteredStackProjects } from "./helpers/stack-e2e-cleanup.ts"; -afterEach(async () => { - await cleanupRegisteredStackProjects(); -}); +afterEach(() => cleanupRegisteredStackProjects()); diff --git a/apps/cli/tests/fixtures/compiled-cli-version.ts b/apps/cli/tests/fixtures/compiled-cli-version.ts index 07b930c371..322d02d647 100644 --- a/apps/cli/tests/fixtures/compiled-cli-version.ts +++ b/apps/cli/tests/fixtures/compiled-cli-version.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-console -- fixture's public contract is its stdout value. import { CLI_VERSION } from "../../src/shared/cli/version.ts"; console.log(CLI_VERSION); diff --git a/apps/cli/tests/fixtures/compiled-libpg-query.ts b/apps/cli/tests/fixtures/compiled-libpg-query.ts index d421a00054..fec9fd4549 100644 --- a/apps/cli/tests/fixtures/compiled-libpg-query.ts +++ b/apps/cli/tests/fixtures/compiled-libpg-query.ts @@ -28,4 +28,4 @@ if (result.files.length !== 1) { throw new Error("analyzeForShadow did not reorder the expected statement"); } -console.log("libpg-query.wasm loaded"); +process.stdout.write("libpg-query.wasm loaded\n"); diff --git a/apps/cli/tests/helpers/cli.ts b/apps/cli/tests/helpers/cli.ts index 51a76fbaaf..32a72ac375 100644 --- a/apps/cli/tests/helpers/cli.ts +++ b/apps/cli/tests/helpers/cli.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- CLI e2e harness owns raw subprocess streams, timers, and temporary filesystem operations. import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; diff --git a/apps/cli/tests/helpers/docker-image.ts b/apps/cli/tests/helpers/docker-image.ts index 880dfb470a..e1fc0841f3 100644 --- a/apps/cli/tests/helpers/docker-image.ts +++ b/apps/cli/tests/helpers/docker-image.ts @@ -1,5 +1,5 @@ import { BunServices } from "@effect/platform-bun"; -import { Cause, Duration, Effect, Layer } from "effect"; +import { Cause, Clock, Data, Duration, Effect, Layer } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process"; import type { ChildProcessSpawner as ChildProcessSpawnerTag } from "effect/unstable/process/ChildProcessSpawner"; @@ -12,6 +12,11 @@ type Spawner = ChildProcessSpawnerTag["Service"]; // (120s): a stalled registry must leave the caller room to run its test body. export const RESOLVE_BUDGET_MS = 90_000; +export class DockerImageResolutionError extends Data.TaggedError("DockerImageResolutionError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + const resolvedImages = new Map<string, Promise<string>>(); /** @@ -80,7 +85,7 @@ export function ensureImage( * window. */ export function resolveDeadline(budgetMs = RESOLVE_BUDGET_MS): number { - return Date.now() + budgetMs; + return Effect.runSync(Clock.currentTimeMillis) + budgetMs; } /** @@ -92,12 +97,21 @@ export function resolveDeadline(budgetMs = RESOLVE_BUDGET_MS): number { * Deliberately a client-only probe: an unreachable daemon is a different * condition that the resolver already reports with its own message. */ -function requireDocker(spawner: Spawner): Effect.Effect<void, Error> { +function requireDocker(spawner: Spawner): Effect.Effect<void, DockerImageResolutionError> { return spawner.exitCode(ChildProcess.make("docker", ["--version"])).pipe( - Effect.mapError(() => new Error("docker is required for this test but could not be spawned")), + Effect.mapError( + (cause) => + new DockerImageResolutionError({ + message: "docker is required for this test but could not be spawned", + cause, + }), + ), Effect.filterOrFail( (exitCode) => Number(exitCode) === 0, - () => new Error("docker is required for this test but exited non-zero"), + () => + new DockerImageResolutionError({ + message: "docker is required for this test but exited non-zero", + }), ), Effect.asVoid, ); @@ -111,31 +125,38 @@ export function resolveImage( spawner: Spawner, image: string, deadline: number, -): Effect.Effect<string, Error> { - const remainingMs = Math.max(1, deadline - Date.now()); - return requireDocker(spawner).pipe( - // The deadline goes INTO the resolver, which divides it across the - // registry candidates — a stalled registry cannot starve the ECR → GHCR → - // Docker Hub fallbacks behind it, and an exhausted share is reported - // against the candidate that spent it. The outer timeout is only a - // backstop for the paths the resolver does not bound (a wedged daemon - // hanging `docker image inspect`); its 1s grace keeps the resolver's own - // richer per-candidate error winning every race it can. - Effect.andThen(() => legacyMakeDockerImageResolver(spawner)(image, deadline)), - Effect.timeout(Duration.millis(remainingMs + 1_000)), - Effect.mapError((cause) => { - if (Cause.isTimeoutError(cause)) { - return new Error( - `timed out resolving ${image} after ${remainingMs}ms — is the docker daemon responding?`, - ); - } - // The registry-pin hint only helps when the registries themselves were - // the problem; gluing it onto a missing binary or an unreachable daemon - // would misdirect the CI triage this helper exists to speed up. - const hint = cause.message.includes("failed to pull docker image from all registries") - ? " (set SUPABASE_INTERNAL_IMAGE_REGISTRY to pin one)" - : ""; - return new Error(`failed to resolve ${image}${hint}: ${cause.message}`); - }), - ); +): Effect.Effect<string, DockerImageResolutionError> { + return Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const remainingMs = Math.max(1, deadline - now); + return yield* requireDocker(spawner).pipe( + // The deadline goes INTO the resolver, which divides it across the + // registry candidates — a stalled registry cannot starve the ECR → GHCR → + // Docker Hub fallbacks behind it, and an exhausted share is reported + // against the candidate that spent it. The outer timeout is only a + // backstop for the paths the resolver does not bound (a wedged daemon + // hanging `docker image inspect`); its 1s grace keeps the resolver's own + // richer per-candidate error winning every race it can. + Effect.andThen(() => legacyMakeDockerImageResolver(spawner, {})(image, deadline)), + Effect.timeout(Duration.millis(remainingMs + 1_000)), + Effect.mapError((cause) => { + if (Cause.isTimeoutError(cause)) { + return new DockerImageResolutionError({ + message: `timed out resolving ${image} after ${remainingMs}ms — is the docker daemon responding?`, + cause, + }); + } + // The registry-pin hint only helps when the registries themselves were + // the problem; gluing it onto a missing binary or an unreachable daemon + // would misdirect the CI triage this helper exists to speed up. + const hint = cause.message.includes("failed to pull docker image from all registries") + ? " (set SUPABASE_INTERNAL_IMAGE_REGISTRY to pin one)" + : ""; + return new DockerImageResolutionError({ + message: `failed to resolve ${image}${hint}: ${cause.message}`, + cause, + }); + }), + ); + }); } diff --git a/apps/cli/tests/helpers/docker-image.unit.test.ts b/apps/cli/tests/helpers/docker-image.unit.test.ts index ca6cb8b35a..244f5ca715 100644 --- a/apps/cli/tests/helpers/docker-image.unit.test.ts +++ b/apps/cli/tests/helpers/docker-image.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Deferred, Effect, Layer, PlatformError, Sink, Stream } from "effect"; +import { Data, Deferred, Effect, Exit, Fiber, Layer, PlatformError, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ensureImage, RESOLVE_BUDGET_MS, resolveDeadline, resolveImage } from "./docker-image.ts"; @@ -63,12 +63,19 @@ function mockSpawner( const IMAGE = "supabase/postgres:17"; +class EnsureImageTestError extends Data.TaggedError("EnsureImageTestError")<{ + readonly cause: unknown; +}> {} + describe("resolveDeadline", () => { - it("defaults to the shared budget and accepts a caller-sized one", () => { - const before = Date.now(); - expect(resolveDeadline()).toBeGreaterThanOrEqual(before + RESOLVE_BUDGET_MS - 50); - expect(resolveDeadline(1_000)).toBeLessThanOrEqual(Date.now() + 1_000); - }); + it.effect("defaults to the shared budget and accepts a caller-sized one", () => + Effect.sync(() => { + const before = resolveDeadline(0); + expect(resolveDeadline()).toBeGreaterThanOrEqual(before + RESOLVE_BUDGET_MS - 50); + const after = resolveDeadline(0); + expect(resolveDeadline(1_000)).toBeLessThanOrEqual(after + 1_000); + }), + ); }); describe("resolveImage", () => { @@ -170,17 +177,20 @@ describe("resolveImage", () => { }); it.live("reports an exhausted share against the candidate that spent it", () => { - const mock = mockSpawner((args) => { - if (args[0] === "--version") return { exitCode: 0 }; - return { exitCode: 1, stderr: "no such image" }; + return Effect.gen(function* () { + const mock = mockSpawner((args) => { + if (args[0] === "--version") return { exitCode: 0 }; + return { exitCode: 1, stderr: "no such image" }; + }); + const now = resolveDeadline(0); + yield* resolveImage(mock.spawner, IMAGE, now - 10_000).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toContain("candidate budget exhausted"); + expect(error.message).toContain("SUPABASE_INTERNAL_IMAGE_REGISTRY"); + }), + ); }); - return resolveImage(mock.spawner, IMAGE, Date.now() - 10_000).pipe( - Effect.flip, - Effect.map((error) => { - expect(error.message).toContain("candidate budget exhausted"); - expect(error.message).toContain("SUPABASE_INTERNAL_IMAGE_REGISTRY"); - }), - ); }); it.live("falls back to the backstop message when the daemon itself hangs", () => { @@ -204,44 +214,73 @@ describe("ensureImage", () => { const layerFor = (mock: ReturnType<typeof mockSpawner>) => Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, mock.spawner); - it("memoizes per image, including across differing deadlines", async () => { - const mock = mockSpawner(() => ({ exitCode: 0 })); - const image = `memo-${Date.now()}-a`; - const first = await ensureImage(image, resolveDeadline(5_000), layerFor(mock)); - const spawnsAfterFirst = mock.spawned.length; - const second = await ensureImage(image, resolveDeadline(60_000), layerFor(mock)); - expect(second).toBe(first); - expect(mock.spawned.length).toBe(spawnsAfterFirst); - }); + const ensureImageEffect = ( + image: string, + deadline: number, + layer: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>, + ) => + Effect.tryPromise({ + try: () => ensureImage(image, deadline, layer), + catch: (cause) => new EnsureImageTestError({ cause }), + }); - it("memoizes failures so the retry ladder is never re-paid", async () => { - const mock = mockSpawner((args) => - args[0] === "--version" ? { exitCode: 0 } : { exitCode: 1, stderr: "no such image" }, - ); - const image = `memo-${Date.now()}-fail`; - await expect(ensureImage(image, Date.now() - 1_000, layerFor(mock))).rejects.toThrow(); - const spawnsAfterFirst = mock.spawned.length; - await expect(ensureImage(image, Date.now() - 1_000, layerFor(mock))).rejects.toThrow(); - expect(mock.spawned.length).toBe(spawnsAfterFirst); - }); + it.effect("memoizes per image, including across differing deadlines", () => + Effect.gen(function* () { + const mock = mockSpawner(() => ({ exitCode: 0 })); + const now = resolveDeadline(0); + const image = `memo-${now}-a`; + const first = yield* ensureImageEffect(image, now + 5_000, layerFor(mock)); + const spawnsAfterFirst = mock.spawned.length; + const second = yield* ensureImageEffect(image, now + 60_000, layerFor(mock)); + expect(second).toBe(first); + expect(mock.spawned.length).toBe(spawnsAfterFirst); + }), + ); - it("serializes distinct images: the second never spawns before the first settles", async () => { - const firstSpawnCounts: Array<number> = []; - const mock = mockSpawner((args) => { - if (args[0] === "--version") return { exitCode: 0 }; - return { exitCode: 1, stderr: "no such image" }; - }); - const imageA = `queue-${Date.now()}-a`; - const imageB = `queue-${Date.now()}-b`; - // Enqueue both before awaiting either; the queue must fully settle A - // (including its failure) before B's first spawn happens. - const a = ensureImage(imageA, Date.now() - 1_000, layerFor(mock)).catch(() => "a-done"); - const spawnsWhenBEnqueued = mock.spawned.length; - const b = ensureImage(imageB, Date.now() - 1_000, layerFor(mock)).catch(() => "b-done"); - expect(mock.spawned.length).toBe(spawnsWhenBEnqueued); - await a; - firstSpawnCounts.push(mock.spawned.length); - await b; - expect(firstSpawnCounts[0]).toBeLessThanOrEqual(mock.spawned.length); - }); + it.effect("memoizes failures so the retry ladder is never re-paid", () => + Effect.gen(function* () { + const mock = mockSpawner((args) => + args[0] === "--version" ? { exitCode: 0 } : { exitCode: 1, stderr: "no such image" }, + ); + const now = resolveDeadline(0); + const image = `memo-${now}-fail`; + const first = yield* ensureImageEffect(image, now - 1_000, layerFor(mock)).pipe(Effect.exit); + expect(Exit.isFailure(first)).toBe(true); + const spawnsAfterFirst = mock.spawned.length; + const second = yield* ensureImageEffect(image, now - 1_000, layerFor(mock)).pipe(Effect.exit); + expect(Exit.isFailure(second)).toBe(true); + expect(mock.spawned.length).toBe(spawnsAfterFirst); + }), + ); + + it.effect("serializes distinct images: the second never spawns before the first settles", () => + Effect.gen(function* () { + const firstSpawnCounts: Array<number> = []; + const mock = mockSpawner((args) => { + if (args[0] === "--version") return { exitCode: 0 }; + return { exitCode: 1, stderr: "no such image" }; + }); + const now = resolveDeadline(0); + const imageA = `queue-${now}-a`; + const imageB = `queue-${now}-b`; + // Enqueue both before awaiting either; the queue must fully settle A + // (including its failure) before B's first spawn happens. + const a = yield* Effect.forkChild( + ensureImageEffect(imageA, now - 1_000, layerFor(mock)).pipe( + Effect.catch(() => Effect.void), + ), + ); + const spawnsWhenBEnqueued = mock.spawned.length; + const b = yield* Effect.forkChild( + ensureImageEffect(imageB, now - 1_000, layerFor(mock)).pipe( + Effect.catch(() => Effect.void), + ), + ); + expect(mock.spawned.length).toBe(spawnsWhenBEnqueued); + yield* Fiber.join(a); + firstSpawnCounts.push(mock.spawned.length); + yield* Fiber.join(b); + expect(firstSpawnCounts[0]).toBeLessThanOrEqual(mock.spawned.length); + }), + ); }); diff --git a/apps/cli/tests/helpers/legacy-local-reset.ts b/apps/cli/tests/helpers/legacy-local-reset.ts index 7749190d23..d914d769cf 100644 --- a/apps/cli/tests/helpers/legacy-local-reset.ts +++ b/apps/cli/tests/helpers/legacy-local-reset.ts @@ -37,14 +37,12 @@ export function mockContainerCliSpawner(route: (args: ReadonlyArray<string>) => spawned.push({ args }); if (command._tag !== "StandardCommand") { - return yield* Effect.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }); } const result = route(args); @@ -91,7 +89,7 @@ function containerNameFromCreateArgs(args: ReadonlyArray<string>): string { } function fakeContainerId(name: string): string { - return [...name] + return Array.from(name) .map((char) => (char.codePointAt(0) ?? 0).toString(16).padStart(2, "0")) .join("") .padEnd(64, "0") diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index fd9b230930..7333e9c7c2 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -1,10 +1,18 @@ -import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; import { BunServices } from "@effect/platform-bun"; import { type ApiClient, makeApiClient, type SupabaseApiConfigError } from "@supabase/api/effect"; -import { Effect, FileSystem, Layer, Option, Redacted, Sink, Stream } from "effect"; +import { + ConfigProvider, + Effect, + FileSystem, + Layer, + Option, + Redacted, + Sink, + Stream, + Schema, +} from "effect"; import { PlatformError, SystemError } from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as HttpClient from "effect/unstable/http/HttpClient"; @@ -46,6 +54,7 @@ import { LegacyTelemetryState } from "../../src/legacy/telemetry/legacy-telemetr import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; import type { Stdin } from "../../src/shared/runtime/stdin.service.ts"; import { LegacyOutputFlag } from "../../src/shared/legacy/global-flags.ts"; +import { makeLegacyViperEnvLayer } from "../../src/shared/legacy/legacy-viper-env.ts"; import type { Output } from "../../src/shared/output/output.service.ts"; import type { ProcessControl } from "../../src/shared/runtime/process-control.service.ts"; import type { RuntimeInfo } from "../../src/shared/runtime/runtime-info.service.ts"; @@ -160,11 +169,9 @@ export function mockLegacyCredentialsTracked( Effect.gen(function* () { deletedRefs.push(projectRef); if (opts.deleteFails === true) { - return yield* Effect.fail( - new LegacyCredentialDeleteError({ - message: "failed to delete project credential: permission denied", - }), - ); + return yield* new LegacyCredentialDeleteError({ + message: "failed to delete project credential: permission denied", + }); } return true; }), @@ -546,11 +553,10 @@ export function mockLegacyPlatformApi( let body: unknown = undefined; if (request.body._tag === "Uint8Array") { const decoded = new TextDecoder().decode(request.body.body); - try { - body = JSON.parse(decoded); - } catch { - body = decoded; - } + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + decoded, + ).pipe(Effect.option); + body = Option.getOrElse(parsed, () => decoded); } const params = UrlParams.toString(request.urlParams); const recorded: LegacyRecordedRequest = { @@ -564,7 +570,7 @@ export function mockLegacyPlatformApi( requests.push(recorded); if (opts.network === "fail") { - return yield* Effect.fail(legacyTransportFailure(request)); + return yield* legacyTransportFailure(request); } if (opts.handler !== undefined) { return yield* opts.handler(request, recorded); @@ -620,7 +626,7 @@ export function mockLegacyPlatformApi( type V1Stubs = Partial<{ readonly [K in keyof ApiClient["v1"]]: ( input: Parameters<ApiClient["v1"][K]>[0], - ) => Effect.Effect<unknown, unknown>; + ) => Effect.Effect<unknown, Error>; }>; export interface MockLegacyPlatformApiServiceOpts { @@ -644,7 +650,7 @@ export function mockLegacyPlatformApiService( Effect.gen(function* () { requests.push({ method: prop, input }); const stub = (stubs as Record<string, unknown>)[prop] as - | ((i: unknown) => Effect.Effect<unknown, unknown>) + | ((i: unknown) => Effect.Effect<unknown, Error>) | undefined; if (stub === undefined) { return yield* Effect.die(`Unmocked LegacyPlatformApi.v1.${prop}`); @@ -713,12 +719,28 @@ export function useLegacyTempWorkdir(prefix = "supabase-legacy-test-"): { } { let root: string | undefined; beforeEach(() => { - root = mkdtempSync(join(tmpdir(), prefix)); + return Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix }); + yield* Effect.sync(() => { + root = directory; + }); + }).pipe(Effect.provide(BunServices.layer)), + ); }); afterEach(() => { - if (root !== undefined) { - rmSync(root, { recursive: true, force: true }); - root = undefined; + const directory = root; + if (directory !== undefined) { + return Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(directory, { recursive: true, force: true }); + yield* Effect.sync(() => { + root = undefined; + }); + }).pipe(Effect.provide(BunServices.layer)), + ); } }); return { @@ -880,15 +902,13 @@ export function mockLegacyShadowContainerCliSpawner( const args = command._tag === "StandardCommand" ? command.args : []; spawned.push({ args }); if (command._tag !== "StandardCommand") { - return yield* Effect.fail( - new PlatformError( - new SystemError({ - _tag: "NotFound", - module: "ChildProcess", - method: "spawn", - description: "spawn failed", - }), - ), + return yield* new PlatformError( + new SystemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "spawn failed", + }), ); } const isLocalDbInspect = @@ -997,6 +1017,8 @@ export interface BuildLegacyTestRuntimeOpts { readonly goOutput?: Option.Option<GoOutputValue>; /** Raw argv seen by the handler (e.g. to exercise an explicit `--yes=false`). */ readonly cliArgs?: ReadonlyArray<string>; + /** Explicit shell environment visible to LegacyViperEnv in this scenario. */ + readonly env?: Record<string, string>; } export function buildLegacyTestRuntime(opts: BuildLegacyTestRuntimeOpts) { @@ -1009,6 +1031,9 @@ export function buildLegacyTestRuntime(opts: BuildLegacyTestRuntimeOpts) { const analytics = (opts.analytics ?? mockAnalytics()).layer; const goOutput = opts.goOutput ?? Option.none<GoOutputValue>(); const httpClient = opts.api.httpClientLayer; + const legacyViperEnv = makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ); // When the caller doesn't expose an HttpClient layer, use a stub that fails // loudly if any code path tries to consume it. Always wiring HttpClient at @@ -1050,6 +1075,7 @@ export function buildLegacyTestRuntime(opts: BuildLegacyTestRuntimeOpts) { BunServices.layer, Layer.succeed(LegacyOutputFlag, goOutput), Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), + legacyViperEnv, linkedProjectCache, telemetry, analytics, diff --git a/apps/cli/tests/helpers/legacy-storage.ts b/apps/cli/tests/helpers/legacy-storage.ts index 5e8ceb34db..833a1e47d0 100644 --- a/apps/cli/tests/helpers/legacy-storage.ts +++ b/apps/cli/tests/helpers/legacy-storage.ts @@ -1,8 +1,5 @@ -import { mkdirSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option } from "effect"; +import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -11,6 +8,8 @@ import { LegacyPlatformApi } from "../../src/legacy/auth/legacy-platform-api.ser import { LegacyPlatformApiFactory } from "../../src/legacy/auth/legacy-platform-api-factory.service.ts"; import { LegacyProjectNotLinkedError } from "../../src/legacy/config/legacy-project-ref.errors.ts"; import { LegacyProjectRefResolver } from "../../src/legacy/config/legacy-project-ref.service.ts"; +import { legacyLocalGatewayHttpClientTestLayer } from "../../src/legacy/shared/legacy-local-gateway-http-client.ts"; +import { makeLegacyViperEnvLayer } from "../../src/shared/legacy/legacy-viper-env.ts"; import { LegacyYesFlag } from "../../src/shared/legacy/global-flags.ts"; import type { OutputFormat } from "../../src/shared/output/types.ts"; import { mockOutput, mockRuntimeInfo, mockStdin, mockTty } from "./mocks.ts"; @@ -73,6 +72,8 @@ export interface SetupLegacyStorageOptions { readonly pipedAnswers?: ReadonlyArray<string>; /** Raw argv seen by the handler (e.g. to exercise an explicit `--yes=false`). */ readonly cliArgs?: ReadonlyArray<string>; + /** Explicit shell environment visible to the legacy Viper compatibility service. */ + readonly env?: Readonly<Record<string, string>>; /** Project ref returned by the resolver for the linked path. */ readonly projectRef?: string; /** api-keys list returned by the Management API mock (linked path). */ @@ -94,15 +95,26 @@ export interface SetupLegacyStorageOptions { * `--local` scoped-global flag value. */ export function setupLegacyStorage(workdir: string, opts: SetupLegacyStorageOptions) { - if (opts.toml !== undefined) { - mkdirSync(join(workdir, "supabase"), { recursive: true }); - writeFileSync(join(workdir, "supabase", "config.toml"), opts.toml); - } - for (const [rel, content] of Object.entries(opts.files ?? {})) { - const abs = join(workdir, rel); - mkdirSync(dirname(abs), { recursive: true }); - writeFileSync(abs, content); - } + const fixtureLayer = Layer.effectDiscard( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const writeFixture = (relativePath: string, content: string) => { + const absolutePath = path.join(workdir, relativePath); + return Effect.gen(function* () { + yield* fs.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fs.writeFileString(absolutePath, content); + }); + }; + + if (opts.toml !== undefined) { + yield* writeFixture("supabase/config.toml", opts.toml); + } + yield* Effect.forEach(Object.entries(opts.files ?? {}), ([relativePath, content]) => + writeFixture(relativePath, content), + ); + }), + ).pipe(Layer.provide(BunServices.layer)); const out = mockOutput({ format: opts.format ?? "text", @@ -203,12 +215,17 @@ export function setupLegacyStorage(workdir: string, opts: SetupLegacyStorageOpti }); const layer = Layer.mergeAll( + fixtureLayer, out.layer, httpLayer, telemetry.layer, linkedCache.layer, mockLegacyCliConfig({ workdir }), BunServices.layer, + makeLegacyViperEnvLayer( + ConfigProvider.fromEnv({ env: opts.env ?? {}, preserveEmptyStrings: true }), + ), + legacyLocalGatewayHttpClientTestLayer(httpLayer), projectRefLayer, Layer.succeed(LegacyPlatformApiFactory, { make: LegacyPlatformApi.pipe(Effect.provide(managementApi.layer)), diff --git a/apps/cli/tests/helpers/live-env.ts b/apps/cli/tests/helpers/live-env.ts index 92c2519b79..f8a3548761 100644 --- a/apps/cli/tests/helpers/live-env.ts +++ b/apps/cli/tests/helpers/live-env.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/process-env -- this module is the explicit outer boundary for live-suite environment configuration. /** Environment-only live-suite configuration. */ export const LIVE_EXIT_TIMEOUT_MS = 240_000; diff --git a/apps/cli/tests/helpers/live-env.unit.test.ts b/apps/cli/tests/helpers/live-env.unit.test.ts index b5704a6a9c..996ff6c913 100644 --- a/apps/cli/tests/helpers/live-env.unit.test.ts +++ b/apps/cli/tests/helpers/live-env.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/process-env -- this test verifies the process-environment compatibility boundary. import { afterEach, describe, expect, it } from "vitest"; import { deriveLiveProjectHost, liveApiUrl, validateLiveConfig } from "./live-env.ts"; diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts index 7beededf64..ce1c07d71e 100644 --- a/apps/cli/tests/helpers/live-project.ts +++ b/apps/cli/tests/helpers/live-project.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-console-in-effect, effecttsgo/global-date, effecttsgo/global-error-in-effect-catch, effecttsgo/global-error-in-effect-failure, effecttsgo/global-fetch-in-effect, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json, effecttsgo/process-env -- live-project orchestration is a foreign subprocess/network test boundary. import { randomBytes, randomUUID } from "node:crypto"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/tests/helpers/live-project.unit.test.ts b/apps/cli/tests/helpers/live-project.unit.test.ts index 4da9bd2bc0..30a9662f08 100644 --- a/apps/cli/tests/helpers/live-project.unit.test.ts +++ b/apps/cli/tests/helpers/live-project.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-error-in-effect-failure -- these tests inject foreign Promise failures into live-project orchestration. import { Effect } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index a5d3e5e848..35aaa3dc7b 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch, effecttsgo/node-builtin-import -- live-suite setup is a foreign filesystem/network boundary. import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -123,10 +124,7 @@ export function requireLiveSuccess( } /** Rethrow a target failure without discarding failures from exact cleanup. */ -export function throwWithCleanup( - primary: unknown | undefined, - cleanup: ReadonlyArray<unknown>, -): void { +export function throwWithCleanup(primary: unknown, cleanup: ReadonlyArray<unknown>): void { if (primary !== undefined) { if (cleanup.length > 0) { throw new AggregateError([primary, ...cleanup], "Live e2e target and cleanup failed"); diff --git a/apps/cli/tests/helpers/macos-signature.ts b/apps/cli/tests/helpers/macos-signature.ts index c306f4d512..6eb68c5fdd 100644 --- a/apps/cli/tests/helpers/macos-signature.ts +++ b/apps/cli/tests/helpers/macos-signature.ts @@ -1,8 +1,9 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as PlatformError from "effect/PlatformError"; import { macIdentifierFor } from "../../scripts/macos-signing.ts"; -import { runCli } from "./release-shell.ts"; +import { runCliEffect } from "./release-shell.ts"; export type SignatureCheckResult = { readonly passed: boolean; @@ -19,52 +20,72 @@ export type SignatureCheckResult = { * it additionally checks the hardened-runtime flag and that Gatekeeper accepts a * quarantined copy, which validates the online notarization ticket. */ -export async function verifyMacSignature(binPath: string): Promise<SignatureCheckResult> { - const binary = path.basename(binPath); - const expectedId = macIdentifierFor(binary); - if (!expectedId) { - return { passed: false, detail: `no expected identifier configured for ${binary}` }; - } +export const verifyMacSignatureEffect = ( + binPath: string, +): Effect.Effect< + SignatureCheckResult, + PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const path = yield* Path.Path; + const binary = path.basename(binPath); + const expectedId = macIdentifierFor(binary); + if (!expectedId) { + return { passed: false, detail: `no expected identifier configured for ${binary}` }; + } - const verify = await runCli("codesign", ["--verify", "--strict", "--verbose=2", binPath]); - if (verify.exitCode !== 0) { - return { - passed: false, - detail: `codesign --verify --strict failed: exit=${verify.exitCode}, stderr=${JSON.stringify(verify.stderr)}`, - }; - } + const verify = yield* runCliEffect("codesign", [ + "--verify", + "--strict", + "--verbose=2", + binPath, + ]); + if (verify.exitCode !== 0) { + return { + passed: false, + detail: `codesign --verify --strict failed: exit=${verify.exitCode}, stderr=${verify.stderr}`, + }; + } - // codesign writes the signature display to stderr. - const display = await runCli("codesign", ["-dvv", binPath]); - const info = [display.stdout, display.stderr].filter(Boolean).join("\n"); + // codesign writes the signature display to stderr. + const display = yield* runCliEffect("codesign", ["-dvv", binPath]); + const info = [display.stdout, display.stderr].filter(Boolean).join("\n"); - // Match the whole identifier value (codesign prints `Identifier=<id>` on its - // own line) so the SFE's `com.supabase.cli` can't satisfy the sidecar's - // `com.supabase.cli-go` by substring. - const actualId = info.match(/^Identifier=(.+)$/m)?.[1]?.trim(); - if (actualId !== expectedId) { - return { passed: false, detail: `expected Identifier=${expectedId}, got:\n${info}` }; - } - if (info.includes("linker-signed")) { - return { passed: false, detail: `signature is still linker-signed:\n${info}` }; - } + // Match the whole identifier value (codesign prints `Identifier=<id>` on its + // own line) so the SFE's `com.supabase.cli` can't satisfy the sidecar's + // `com.supabase.cli-go` by substring. + const actualId = info.match(/^Identifier=(.+)$/m)?.[1]?.trim(); + if (actualId !== expectedId) { + return { passed: false, detail: `expected Identifier=${expectedId}, got:\n${info}` }; + } + if (info.includes("linker-signed")) { + return { passed: false, detail: `signature is still linker-signed:\n${info}` }; + } - if (!info.includes("Authority=Developer ID Application")) { - // Phase 1: full ad-hoc signature. - if (!info.includes("Signature=adhoc") && !info.includes("adhoc")) { - return { passed: false, detail: `expected an ad-hoc signature, got:\n${info}` }; + if (!info.includes("Authority=Developer ID Application")) { + // Phase 1: full ad-hoc signature. + if (!info.includes("Signature=adhoc") && !info.includes("adhoc")) { + return { passed: false, detail: `expected an ad-hoc signature, got:\n${info}` }; + } + return { passed: true, detail: `ad-hoc signature ok (Identifier=${expectedId})` }; } - return { passed: true, detail: `ad-hoc signature ok (Identifier=${expectedId})` }; - } - // Phase 2: Developer ID + notarization. - if (!info.includes("runtime")) { - return { - passed: false, - detail: `Developer ID signature missing hardened runtime flag:\n${info}`, - }; - } - return verifyGatekeeperAcceptsQuarantined(binPath, expectedId); + // Phase 2: Developer ID + notarization. + if (!info.includes("runtime")) { + return { + passed: false, + detail: `Developer ID signature missing hardened runtime flag:\n${info}`, + }; + } + return yield* verifyGatekeeperAcceptsQuarantined(binPath, expectedId); + }); + +/** Promise facade for the macOS smoke script's non-Effect executable edge. */ +export function verifyMacSignature(binPath: string): Promise<SignatureCheckResult> { + return Effect.runPromise( + verifyMacSignatureEffect(binPath).pipe(Effect.provide(BunServices.layer)), + ); } /** @@ -73,27 +94,39 @@ export async function verifyMacSignature(binPath: string): Promise<SignatureChec * un-notarized one is rejected. Bare Mach-O binaries cannot be stapled, so this * exercises the online ticket lookup. */ -async function verifyGatekeeperAcceptsQuarantined( +function verifyGatekeeperAcceptsQuarantined( binPath: string, expectedId: string, -): Promise<SignatureCheckResult> { - const dir = await mkdtemp(path.join(tmpdir(), "supabase-gatekeeper-")); - const copy = path.join(dir, path.basename(binPath)); - try { - await runCli("cp", [binPath, copy]); - await runCli("xattr", ["-w", "com.apple.quarantine", "0081;00000000;smoke;", copy]); - const assess = await runCli("spctl", ["--assess", "--type", "execute", "--verbose=2", copy]); - if (assess.exitCode !== 0) { +): Effect.Effect< + SignatureCheckResult, + PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectory({ prefix: "supabase-gatekeeper-" }); + const copy = path.join(dir, path.basename(binPath)); + return yield* Effect.gen(function* () { + yield* runCliEffect("cp", [binPath, copy]); + yield* runCliEffect("xattr", ["-w", "com.apple.quarantine", "0081;00000000;smoke;", copy]); + const assess = yield* runCliEffect("spctl", [ + "--assess", + "--type", + "execute", + "--verbose=2", + copy, + ]); + if (assess.exitCode !== 0) { + return { + passed: false, + detail: `spctl rejected quarantined binary: exit=${assess.exitCode}, stderr=${assess.stderr}`, + }; + } return { - passed: false, - detail: `spctl rejected quarantined binary: exit=${assess.exitCode}, stderr=${JSON.stringify(assess.stderr)}`, + passed: true, + detail: `Developer ID + notarized signature ok (Identifier=${expectedId})`, }; - } - return { - passed: true, - detail: `Developer ID + notarized signature ok (Identifier=${expectedId})`, - }; - } finally { - await rm(dir, { recursive: true, force: true }); - } + }).pipe(Effect.ensuring(fs.remove(dir, { recursive: true }).pipe(Effect.ignore))); + }); } diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 3c02f58447..decf7e8d63 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -1,8 +1,19 @@ import { tmpdir } from "node:os"; -import { join } from "node:path"; import process from "node:process"; import { BunServices } from "@effect/platform-bun"; -import { Deferred, Effect, Layer, Option, PubSub, Redacted, Stream } from "effect"; +import { + ConfigProvider, + Deferred, + Effect, + Layer, + Option, + Path, + PubSub, + Redacted, + Random, + Schema, + Stream, +} from "effect"; import type { ReactElement } from "react"; import type { ProjectEnvironment, ProjectPaths } from "@supabase/config"; import { Stack, StackServiceState, type StackInfo } from "@supabase/stack/effect"; @@ -18,7 +29,10 @@ import { ProjectLocalServiceVersions, type LocalServiceVersionsState, } from "../../src/next/config/project-local-service-versions.service.ts"; -import { ProjectLinkRemote } from "../../src/next/config/project-link-remote.service.ts"; +import { + NoProjectApiKeyError, + ProjectLinkRemote, +} from "../../src/next/config/project-link-remote.service.ts"; import { ProjectLinkState, type ProjectLinkStateValue, @@ -62,25 +76,27 @@ type OutputEvent = { [key: string]: unknown; }; -// Default home for mocks that need *some* path value. Unique per process (never +// Default home for mocks that need *some* path value. Unique per module evaluation (never // created on disk here) so a test that accidentally combines this default with a // real FileSystem layer can never pick up stale files written by earlier test // runs or manual CLI invocations — the failure mode the previous fixed literal // `/tmp/supabase-cli-test-home` allowed. Tests that really read or write files // under homeDir must pass their own per-test temp dir instead (see // `useLegacyTempWorkdir` in `legacy-mocks.ts`). -const defaultTestHomeDir = join( - tmpdir(), - `supabase-cli-test-home-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 8)}`, -); +const defaultTestHomeDir = `${tmpdir()}/supabase-cli-test-home-${process.pid.toString(36)}-${Effect.runSync(Random.nextInt).toString(36)}`; // --------------------------------------------------------------------------- // Stateless mocks // --------------------------------------------------------------------------- -export function mockBrowser(): Layer.Layer<Browser> { +export function mockBrowser( + opts: { readonly onOpen?: (url: string) => void } = {}, +): Layer.Layer<Browser> { return Layer.succeed(Browser, { - open: () => Effect.void, + open: (url) => + Effect.sync(() => { + opts.onOpen?.(url); + }), }); } @@ -90,8 +106,8 @@ export function mockCrypto(token = "sbp_" + "a".repeat(40)): Layer.Layer<Crypto> ecdh: {} as import("node:crypto").ECDH, publicKeyHex: "04abcd", })), - generateSessionId: Effect.sync(() => "test-session-id"), - defaultTokenName: Effect.sync(() => "cli_test@host_123"), + generateSessionId: Effect.succeed("test-session-id"), + defaultTokenName: Effect.succeed("cli_test@host_123"), decryptToken: () => Effect.succeed(token), }); } @@ -156,6 +172,7 @@ export function mockRuntimeInfo( homeDir?: string; execPath?: string; pid?: number; + osUser?: string; } = {}, ): Layer.Layer<RuntimeInfo> { return Layer.succeed(RuntimeInfo, { @@ -165,6 +182,7 @@ export function mockRuntimeInfo( homeDir: opts.homeDir ?? defaultTestHomeDir, execPath: opts.execPath ?? "/test/bin/bun", pid: opts.pid ?? 1234, + osUser: opts.osUser, }); } @@ -179,31 +197,32 @@ export function mockProcessControl( const exitCalls: number[] = []; const exitDeferred = Deferred.makeUnsafe<number>(); + const layer: Layer.Layer<ProcessControl> = Layer.succeed(ProcessControl, { + awaitSignal: (signals = ["SIGINT", "SIGTERM"]) => { + if (opts.awaitSignal !== undefined) { + return opts.awaitSignal; + } + if (opts.signal !== undefined && signals.includes(opts.signal)) { + return Effect.succeed(opts.signal); + } + return Effect.never; + }, + awaitShutdown: opts.awaitShutdown ?? Effect.never, + holdSignals: (_signals) => Effect.void, + exit: (code: number) => + Effect.gen(function* () { + exitCalls.push(code); + yield* Deferred.succeed(exitDeferred, code); + return yield* Effect.never; + }), + setExitCode: (code: number) => + Effect.sync(() => { + exitCode = code; + }), + getExitCode: Effect.sync(() => exitCode), + }); return { - layer: Layer.succeed(ProcessControl, { - awaitSignal: (signals = ["SIGINT", "SIGTERM"]) => { - if (opts.awaitSignal !== undefined) { - return opts.awaitSignal; - } - if (opts.signal !== undefined && signals.includes(opts.signal)) { - return Effect.succeed(opts.signal); - } - return Effect.never; - }, - awaitShutdown: opts.awaitShutdown ?? Effect.never, - holdSignals: (_signals) => Effect.void, - exit: (code: number) => - Effect.gen(function* () { - exitCalls.push(code); - yield* Deferred.succeed(exitDeferred, code); - return yield* Effect.never; - }), - setExitCode: (code: number) => - Effect.sync(() => { - exitCode = code; - }), - getExitCode: Effect.sync(() => exitCode), - }), + layer, get exitCalls() { return exitCalls; }, @@ -347,15 +366,15 @@ export function mockOutput( }; }), event: (event) => - Effect.sync(() => { + Effect.gen(function* () { + const message = + event.type === "log-entry" + ? `[${event.service}] ${event.line}` + : yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))( + event, + ).pipe(Effect.orDie); events.push(event as OutputEvent); - messages.push({ - type: "info", - message: - event.type === "log-entry" - ? `[${event.service}] ${event.line}` - : JSON.stringify(event), - }); + messages.push({ type: "info", message }); }), success: (message: string, data?: Record<string, unknown>) => Effect.sync(() => { @@ -586,31 +605,34 @@ export function mockTelemetryRuntime( cliVersion: string; }> = {}, ): Layer.Layer<TelemetryRuntime> { - return Layer.succeed( + return Layer.effect( TelemetryRuntime, - TelemetryRuntime.of({ - configDir: opts.configDir ?? join(defaultTestHomeDir, ".supabase"), - tracesDir: opts.tracesDir ?? join(defaultTestHomeDir, ".supabase", "traces"), - consent: opts.consent ?? "granted", - showDebug: opts.showDebug ?? false, - deviceId: opts.deviceId ?? "test-device-id", - sessionId: opts.sessionId ?? "test-session-id", - identity: makeTelemetryIdentity(opts.distinctId), - isFirstRun: opts.isFirstRun ?? false, - isTty: opts.isTty ?? false, - isCi: opts.isCi ?? false, - os: opts.os ?? "linux", - arch: opts.arch ?? "x64", - cliVersion: opts.cliVersion ?? "0.1.0", + Effect.gen(function* () { + const path = yield* Path.Path; + const configDir = opts.configDir ?? path.join(defaultTestHomeDir, ".supabase"); + return TelemetryRuntime.of({ + configDir, + tracesDir: opts.tracesDir ?? path.join(configDir, "traces"), + consent: opts.consent ?? "granted", + showDebug: opts.showDebug ?? false, + deviceId: opts.deviceId ?? "test-device-id", + sessionId: opts.sessionId ?? "test-session-id", + identity: makeTelemetryIdentity(opts.distinctId), + isFirstRun: opts.isFirstRun ?? false, + isTty: opts.isTty ?? false, + isCi: opts.isCi ?? false, + os: opts.os ?? "linux", + arch: opts.arch ?? "x64", + cliVersion: opts.cliVersion ?? "0.1.0", + }); }), - ); + ).pipe(Layer.provide(BunServices.layer)); } export function mockStack( opts: { info?: Partial<StackInfo>; stateChanges?: Array<{ name: string; status: StackServiceState["status"] }>; - startError?: unknown; startPending?: boolean; stopPending?: boolean; liveStateChanges?: boolean; @@ -657,9 +679,6 @@ export function mockStack( start: () => Effect.gen(function* () { started = true; - if (opts.startError !== undefined) { - return yield* Effect.fail(opts.startError as never); - } if (opts.startPending) { yield* Deferred.await(startDeferred); } @@ -695,28 +714,29 @@ export function mockStack( error: null, }), ), - getAllStates: () => { - const latestStates = new Map( - (stateHistory.length > 0 - ? stateHistory - : [{ name: "postgres", status: "Pending" as const }] - ).map((state) => [state.name, state] as const), - ); - return Effect.succeed( - [...latestStates.values()].map( - (state) => - new StackServiceState({ - name: state.name, - status: state.status, - pid: null, - exitCode: null, - restartCount: 0, - startedAt: null, - error: null, - }), - ), - ); - }, + getAllStates: () => + Effect.suspend(() => { + const latestStates = new Map( + (stateHistory.length > 0 + ? stateHistory + : [{ name: "postgres", status: "Pending" as const }] + ).map((state) => [state.name, state] as const), + ); + return Effect.succeed( + [...latestStates.values()].map( + (state) => + new StackServiceState({ + name: state.name, + status: state.status, + pid: null, + exitCode: null, + restartCount: 0, + startedAt: null, + error: null, + }), + ), + ); + }), stateChanges: () => Effect.succeed(Stream.empty), allStateChanges: () => opts.liveStateChanges @@ -779,10 +799,7 @@ export function mockInk(opts: { manualExit?: boolean } = {}) { let rendered = false; let unmounted = false; let element: ReactElement | null = null; - let resolveExit = () => {}; - const exitPromise = new Promise<unknown>((resolve) => { - resolveExit = () => resolve(undefined); - }); + const exitDeferred = Deferred.makeUnsafe<void>(); return { layer: Layer.succeed(Ink, { render: (nextElement) => @@ -796,7 +813,8 @@ export function mockInk(opts: { manualExit?: boolean } = {}) { rerender: (updatedElement) => { element = updatedElement; }, - waitUntilExit: () => (opts.manualExit ? exitPromise : Promise.resolve()), + waitUntilExit: () => + opts.manualExit ? Effect.runPromise(Deferred.await(exitDeferred)) : Promise.resolve(), } satisfies InkInstance; }), }), @@ -810,7 +828,7 @@ export function mockInk(opts: { manualExit?: boolean } = {}) { return element; }, exit() { - resolveExit(); + Effect.runSync(Deferred.succeed(exitDeferred, undefined)); }, }; } @@ -819,34 +837,14 @@ export function mockInk(opts: { manualExit?: boolean } = {}) { // Environment helpers // --------------------------------------------------------------------------- -function applyProcessEnv(values: Readonly<Record<string, string | undefined>>) { - const snapshot = { ...process.env }; - - for (const key of Object.keys(process.env)) { - delete process.env[key]; - } - - for (const [key, value] of Object.entries(values)) { - if (value !== undefined) { - process.env[key] = value; - } - } - - return snapshot; -} - export function processEnvLayer( values: Readonly<Record<string, string | undefined>> = {}, ): Layer.Layer<never> { - return Layer.effectDiscard( - Effect.acquireRelease( - Effect.sync(() => applyProcessEnv(values)), - (snapshot) => - Effect.sync(() => { - applyProcessEnv(snapshot); - }), - ), - ); + const env: Record<string, string> = {}; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined) env[key] = value; + } + return ConfigProvider.layer(ConfigProvider.fromEnv({ env, preserveEmptyStrings: true })); } export function mockProjectContext( @@ -961,7 +959,7 @@ export function mockProjectLinkRemote( fetchLinkedProject: (projectRef: string) => Effect.gen(function* () { if (linkedProject === undefined) { - return yield* Effect.fail(new Error(`No linked project mock for ${projectRef}`)); + return yield* new NoProjectApiKeyError({ projectRef }); } return { ...linkedProject, @@ -988,10 +986,12 @@ export function mockProjectLocalServiceVersions( ); } -export function emptyEnv() { +export function emptyEnv( + opts: { readonly env?: Readonly<Record<string, string | undefined>> } = {}, +) { const runtimeInfoLayer = mockRuntimeInfo(); const projectContextLayer = mockProjectContext(); - const envLayer = processEnvLayer(); + const envLayer = processEnvLayer(opts.env); const projectHomeLayer = mockProjectHome(); const projectLinkStateLayer = mockProjectLinkState(); const projectLocalServiceVersionsLayer = mockProjectLocalServiceVersions(); @@ -1005,10 +1005,14 @@ export function emptyEnv() { projectLocalServiceVersionsLayer, analytics.layer, mockTelemetryRuntime(), - envLayer, mockTty(), mockProcessControl().layer, - cliConfigLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer)), + cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provideMerge(envLayer), + Layer.provideMerge(BunServices.layer), + ), Layer.succeed(HttpTransportClient, { request: () => Effect.die("unexpected HttpTransportClient access in tests"), }), @@ -1028,9 +1032,13 @@ export function withEnv(env: Record<string, string>) { projectHomeLayer, analytics.layer, mockTelemetryRuntime(), - envLayer, mockTty(), mockProcessControl().layer, - cliConfigLayer.pipe(Layer.provide(runtimeInfoLayer), Layer.provide(projectContextLayer)), + cliConfigLayer.pipe( + Layer.provide(runtimeInfoLayer), + Layer.provide(projectContextLayer), + Layer.provideMerge(envLayer), + Layer.provideMerge(BunServices.layer), + ), ); } diff --git a/apps/cli/tests/helpers/npm-registry.ts b/apps/cli/tests/helpers/npm-registry.ts index 7aa53d6d53..8c8cd3b0e5 100644 --- a/apps/cli/tests/helpers/npm-registry.ts +++ b/apps/cli/tests/helpers/npm-registry.ts @@ -1,21 +1,19 @@ -import { $ } from "bun"; -import { - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - readlink, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import process from "node:process"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Option, Schedule, Schema, Stream } from "effect"; +import * as EffectPath from "effect/Path"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type { PlatformError } from "effect/PlatformError"; +import type * as Scope from "effect/Scope"; import { runCli, verifyExpectedShell } from "./release-shell.ts"; -const root = path.resolve(import.meta.dir, "../../../.."); +const { resolve, join, relative } = Effect.runSync( + EffectPath.Path.pipe(Effect.provide(BunPath.layer)), +); +const root = resolve(import.meta.dir, "../../../.."); const PACKAGE_PATHS = { "cli-darwin-arm64": ["packages", "cli-darwin-arm64"], @@ -29,367 +27,534 @@ const PACKAGE_PATHS = { cli: ["apps", "cli"], } as const; -const ALL_PACKAGES = Object.keys(PACKAGE_PATHS) as Array<keyof typeof PACKAGE_PATHS>; +const ALL_PACKAGES = [ + "cli-darwin-arm64", + "cli-darwin-x64", + "cli-linux-arm64", + "cli-linux-arm64-musl", + "cli-linux-x64", + "cli-linux-x64-musl", + "cli-windows-arm64", + "cli-windows-x64", + "cli", +] as const; + +type PackageName = (typeof ALL_PACKAGES)[number]; + +class NpmRegistryError extends Data.TaggedError("NpmRegistryError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +interface CommandResult { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} -export async function createTmpDir(prefix: string): Promise<AsyncDisposable & { path: string }> { - const dir = await mkdtemp(path.join(tmpdir(), prefix)); - return { - path: dir, - async [Symbol.asyncDispose]() { - await rm(dir, { recursive: true }); - }, - }; +interface CommandOptions { + readonly ignoreOutput?: boolean; + readonly cwd?: string; + readonly env?: Record<string, string | undefined>; + readonly extendEnv?: boolean; } -async function startVerdaccio( - configPath: string, - port: number, -): Promise<AsyncDisposable & { url: string }> { - const url = `http://localhost:${port}`; - const proc = Bun.spawn(["bunx", "verdaccio", "--config", configPath], { - stdout: "ignore", - stderr: "ignore", +const runCommand = ( + command: string | ReadonlyArray<string>, + args: ReadonlyArray<string> = [], + options: CommandOptions = {}, +): Effect.Effect<CommandResult, PlatformError, never> => { + const cmd = typeof command === "string" ? [command, ...args] : [...command]; + const executable = cmd[0]; + if (executable === undefined) { + return Effect.die("command cannot be empty"); + } + const captureOutput = options.ignoreOutput !== true; + return Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(executable, cmd.slice(1), { + cwd: options.cwd, + env: options.env, + extendEnv: options.extendEnv, + stdout: captureOutput ? "pipe" : "ignore", + stderr: captureOutput ? "pipe" : "ignore", + }), + ); + return yield* Effect.all( + { + status: handle.exitCode, + stdout: captureOutput + ? Stream.mkString(Stream.decodeText(handle.stdout)) + : Effect.succeed(""), + stderr: captureOutput + ? Stream.mkString(Stream.decodeText(handle.stderr)) + : Effect.succeed(""), + }, + { concurrency: "unbounded" }, + ); + }), + ).pipe(Effect.provide(BunServices.layer)); +}; + +const readFileString = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path, "utf8"); }); - const timeout = 120_000; - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - try { - const res = await fetch(`${url}/-/ping`); - if (res.ok) return { url, [Symbol.asyncDispose]: async () => proc.kill() }; - } catch { - // not ready yet - } - await Bun.sleep(500); - } +const writeFileString = (path: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(path, contents); + }); - proc.kill(); - throw new Error(`Verdaccio failed to start within ${timeout / 1000}s`); -} +const remove = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { recursive: true, force: true }); + }); -async function savePackageJsons() { - const originals = new Map<string, string>(); - for (const pkg of ALL_PACKAGES) { - const p = path.join(root, ...PACKAGE_PATHS[pkg], "package.json"); - originals.set(p, await readFile(p, "utf-8")); - } - return { - async [Symbol.asyncDispose]() { - for (const [p, content] of originals) { - await writeFile(p, content); - } - }, - }; +const encodeJson = (value: unknown) => + Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown))(value).pipe( + Effect.mapError((cause) => new NpmRegistryError({ message: "JSON encoding failed", cause })), + ); + +interface TmpDir { + readonly path: string; + readonly [Symbol.asyncDispose]: () => Promise<void>; } -function modeOctal(mode: number): string { - return `0${(mode & 0o777).toString(8).padStart(3, "0")}`; +const createTmpDirEffect = ( + prefix: string, +): Effect.Effect<TmpDir, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* fs.makeTempDirectory({ prefix }); + return { + path, + [Symbol.asyncDispose]: () => disposeTmpDir(path), + } satisfies TmpDir; + }); + +const disposeTmpDir = (path: string): Promise<void> => + Effect.runPromise(remove(path).pipe(Effect.provide(BunServices.layer))); + +/** Promise facade consumed by the outer smoke scripts. Core setup is Effect-native. */ +export function createTmpDir(prefix: string): Promise<TmpDir> { + return Effect.runPromise(createTmpDirEffect(prefix).pipe(Effect.provide(BunServices.layer))); } -async function describePath(p: string): Promise<string> { - try { - const ls = await lstat(p); - if (ls.isSymbolicLink()) { - const target = await readlink(p); - try { - const st = await stat(p); - return `symlink ${modeOctal(ls.mode)} -> ${target} (target ${modeOctal(st.mode)} ${st.size}B)`; - } catch (e) { - return `symlink ${modeOctal(ls.mode)} -> ${target} (target unreadable: ${e})`; - } +const httpPing = (url: string): Effect.Effect<void, NpmRegistryError, HttpClient.HttpClient> => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client + .execute(HttpClientRequest.get(`${url}/-/ping`)) + .pipe( + Effect.mapError( + (cause) => new NpmRegistryError({ message: "registry readiness request failed", cause }), + ), + ); + if (response.status < 200 || response.status >= 300) { + return yield* new NpmRegistryError({ message: `registry returned HTTP ${response.status}` }); } - return `file ${modeOctal(ls.mode)} ${ls.size}B`; - } catch (e) { - if ((e as NodeJS.ErrnoException).code === "ENOENT") return "MISSING"; - return `unstattable: ${e}`; - } -} + yield* response.text.pipe( + Effect.mapError( + (cause) => new NpmRegistryError({ message: "registry readiness body failed", cause }), + ), + ); + }); -async function dumpInstalledTree(testDir: string, ext: string): Promise<void> { - console.log("\nInstalled tree state:"); - const interesting = [ - path.join(testDir, "node_modules", ".bin", `supabase${ext}`), - path.join(testDir, "node_modules", "supabase", "package.json"), - path.join(testDir, "node_modules", "supabase", "dist", "supabase.js"), - ]; - for (const p of interesting) { - console.log(` ${path.relative(testDir, p)}: ${await describePath(p)}`); - } +const startVerdaccio = ( + configPath: string, + port: number, +): Effect.Effect< + { readonly url: string }, + PlatformError | NpmRegistryError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope +> => + Effect.gen(function* () { + const url = `http://localhost:${port}`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + yield* spawner.spawn( + ChildProcess.make("bunx", ["verdaccio", "--config", configPath], { + stdout: "ignore", + stderr: "ignore", + }), + ); + const readiness = Schedule.recurs(240).pipe(Schedule.addDelay(() => Effect.succeed(500))); + yield* httpPing(url).pipe(Effect.retry(readiness), Effect.provide(FetchHttpClient.layer)); + return { url }; + }); - const supabaseScope = path.join(testDir, "node_modules", "@supabase"); - let scopeEntries: string[] = []; - try { - scopeEntries = await readdir(supabaseScope); - } catch { - console.log(` node_modules/@supabase: MISSING`); - return; - } - for (const entry of scopeEntries.sort()) { - const pkgDir = path.join(supabaseScope, entry); - const pkgJsonPath = path.join(pkgDir, "package.json"); - try { - const pkgJson = JSON.parse(await readFile(pkgJsonPath, "utf-8")); - console.log(` node_modules/@supabase/${entry}: ${pkgJson.name}@${pkgJson.version}`); - } catch { - console.log(` node_modules/@supabase/${entry}: <unreadable package.json>`); +const packageJsonPath = (pkg: PackageName): string => + join(root, ...PACKAGE_PATHS[pkg], "package.json"); + +const savePackageJsons = () => + Effect.gen(function* () { + const originals = new Map<string, string>(); + for (const pkg of ALL_PACKAGES) { + const path = packageJsonPath(pkg); + originals.set(path, yield* readFileString(path)); + } + return originals; + }); + +const restorePackageJsons = (originals: ReadonlyMap<string, string>) => + Effect.forEach(originals, ([path, content]) => writeFileString(path, content), { + concurrency: "unbounded", + discard: true, + }); + +const modeOctal = (mode: number): string => `0${(mode & 0o777).toString(8).padStart(3, "0")}`; + +const describePath = (path: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const info = yield* fs.stat(path); + const link = yield* fs.readLink(path).pipe(Effect.option); + if (Option.isSome(link)) { + return `symlink ${modeOctal(info.mode)} -> ${link.value} (${info.size}B)`; + } + return `file ${modeOctal(info.mode)} ${info.size}B`; + }).pipe( + Effect.catch((error) => + error instanceof Error && error.message.includes("NotFound") + ? Effect.succeed("MISSING") + : Effect.succeed(`unstattable: ${String(error)}`), + ), + ); + +const dumpInstalledTree = ( + testDir: string, + ext: string, +): Effect.Effect<void, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* Effect.log("\nInstalled tree state:"); + const interesting = [ + join(testDir, "node_modules", ".bin", `supabase${ext}`), + join(testDir, "node_modules", "supabase", "package.json"), + join(testDir, "node_modules", "supabase", "dist", "supabase.js"), + ]; + for (const path of interesting) { + yield* Effect.log(` ${relative(testDir, path)}: ${yield* describePath(path)}`); } - const binDir = path.join(pkgDir, "bin"); - try { - const binEntries = await readdir(binDir); - for (const b of binEntries.sort()) { - const bp = path.join(binDir, b); - console.log(` bin/${b}: ${await describePath(bp)}`); + + const supabaseScope = join(testDir, "node_modules", "@supabase"); + const scopeEntries = yield* fs + .readDirectory(supabaseScope) + .pipe(Effect.orElseSucceed(() => [])); + if (scopeEntries.length === 0) { + yield* Effect.log(" node_modules/@supabase: MISSING"); + return; + } + for (const entry of scopeEntries.sort((left, right) => left.localeCompare(right))) { + const pkgDir = join(supabaseScope, entry); + const pkgJsonPath = join(pkgDir, "package.json"); + const pkgJsonText = yield* readFileString(pkgJsonPath).pipe(Effect.orElseSucceed(() => "")); + const pkgJson = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ name: Schema.String, version: Schema.String })), + )(pkgJsonText).pipe( + Effect.mapError( + (cause) => new NpmRegistryError({ message: "package JSON decoding failed", cause }), + ), + Effect.option, + ); + if (Option.isSome(pkgJson)) { + yield* Effect.log( + ` node_modules/@supabase/${entry}: ${pkgJson.value.name}@${pkgJson.value.version}`, + ); + } else { + yield* Effect.log(` node_modules/@supabase/${entry}: <unreadable package.json>`); + } + const binDir = join(pkgDir, "bin"); + const binEntries = yield* fs.readDirectory(binDir).pipe(Effect.orElseSucceed(() => [])); + for (const bin of binEntries.sort((left, right) => left.localeCompare(right))) { + const binPath = join(binDir, bin); + yield* Effect.log(` bin/${bin}: ${yield* describePath(binPath)}`); } - } catch { - // no bin/ } - } -} + }); -async function findPlatformBinary(testDir: string, ext: string): Promise<string | null> { - const supabaseScope = path.join(testDir, "node_modules", "@supabase"); - let entries: string[] = []; - try { - entries = await readdir(supabaseScope); - } catch { - return null; - } - for (const entry of entries) { - const candidate = path.join(supabaseScope, entry, "bin", `supabase${ext}`); - try { - await stat(candidate); - return candidate; - } catch { - // not this one +const findPlatformBinary = (testDir: string, ext: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const supabaseScope = join(testDir, "node_modules", "@supabase"); + const entries = yield* fs.readDirectory(supabaseScope).pipe(Effect.orElseSucceed(() => [])); + for (const entry of entries) { + const candidate = join(supabaseScope, entry, "bin", `supabase${ext}`); + if (yield* fs.exists(candidate)) { + return Option.some(candidate); + } } - } - return null; -} + return Option.none<string>(); + }); -async function inspectVerdaccioTarball(storageDir: string, pkg: string): Promise<void> { - const pkgStorage = path.join(storageDir, "@supabase", pkg); - let files: string[] = []; - try { - files = await readdir(pkgStorage); - } catch { - console.log(` @supabase/${pkg}: <no tarball in verdaccio storage>`); - return; - } - const tarball = files.find((f) => f.endsWith(".tgz")); - if (!tarball) { - console.log(` @supabase/${pkg}: <no .tgz under ${pkgStorage}>`); - return; - } - const tarballPath = path.join(pkgStorage, tarball); - const listing = await $`tar -tvf ${tarballPath}`.text(); - // Surface only the bin entries — full listings drown the log. - const binLines = listing - .split("\n") - .filter((line) => line.includes("/bin/")) - .map((line) => ` ${line.trim()}`); - if (binLines.length === 0) { - console.log(` @supabase/${pkg} (${tarball}): <no bin/ entries>`); - return; - } - console.log(` @supabase/${pkg} (${tarball}):`); - for (const line of binLines) console.log(line); -} +const inspectVerdaccioTarball = (storageDir: string, pkg: string) => + Effect.gen(function* () { + const storage = join(storageDir, "@supabase", pkg); + const fs = yield* FileSystem.FileSystem; + const files = yield* fs.readDirectory(storage).pipe(Effect.orElseSucceed(() => [])); + const tarball = files.find((file) => file.endsWith(".tgz")); + if (tarball === undefined) { + yield* Effect.log(` @supabase/${pkg}: <no tarball in verdaccio storage>`); + return; + } + const listing = yield* runCommand("tar", ["-tvf", join(storage, tarball)]); + const binLines = listing.stdout + .split("\n") + .filter((line) => line.includes("/bin/")) + .map((line) => ` ${line.trim()}`); + if (binLines.length === 0) { + yield* Effect.log(` @supabase/${pkg} (${tarball}): <no bin/ entries>`); + return; + } + yield* Effect.log(` @supabase/${pkg} (${tarball}):`); + yield* Effect.forEach(binLines, (line) => Effect.log(line), { discard: true }); + }); -async function hasVerdaccioTarball(storageDir: string, pkg: string): Promise<boolean> { - const pkgStorage = path.join(storageDir, "@supabase", pkg); - try { - const files = await readdir(pkgStorage); - return files.some((f) => f.endsWith(".tgz")); - } catch { - return false; - } -} +const hasVerdaccioTarball = ( + storageDir: string, + pkg: string, +): Effect.Effect<boolean, never, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* fs + .readDirectory(join(storageDir, "@supabase", pkg)) + .pipe(Effect.orElseSucceed(() => [])); + return files.some((file) => file.endsWith(".tgz")); + }); -export function describeError(e: unknown): string { - if (e instanceof Error) { - const parts = [e.stack ?? `${e.name}: ${e.message}`]; - const stdout = (e as { stdout?: unknown }).stdout; - const stderr = (e as { stderr?: unknown }).stderr; +export function describeError(error: unknown): string { + if (error instanceof Error) { + const parts = [error.stack ?? `${error.name}: ${error.message}`]; + const stdout = Reflect.get(error, "stdout"); + const stderr = Reflect.get(error, "stderr"); if (stdout != null) parts.push(`stdout: ${String(stdout).trim()}`); if (stderr != null) parts.push(`stderr: ${String(stderr).trim()}`); return parts.join("\n"); } - return String(e); + return String(error); } -export async function runNpmTest( - version: string, - tag: "latest" | "alpha" | "beta" = "latest", -): Promise<boolean> { - await using _pkgJsons = await savePackageJsons(); - await using tmp = await createTmpDir("npm-smoke-"); - - const PORT = 4873; - const configPath = path.join(tmp.path, "config.yaml"); - const storageDir = path.join(tmp.path, "storage"); - - // Verdaccio config: store our published tarballs locally. The umbrella - // package is shim-only at runtime and should resolve only our own - // `@supabase/cli-*` optional dependencies from this registry; the public npm - // uplink is retained for npm installer internals and any incidental tooling. - await writeFile( - configPath, - `storage: ${storageDir} -auth: - htpasswd: - file: ${path.join(tmp.path, "htpasswd")} - max_users: 100 -uplinks: - npmjs: - url: https://registry.npmjs.org/ -packages: - "supabase": - access: $all - publish: $all - "@supabase/*": - access: $all - publish: $all - "**": - access: $all - publish: $all - proxy: npmjs -max_body_size: 200mb -listen: 0.0.0.0:${PORT} -`, - ); +const PackageManifest = Schema.Struct({ name: Schema.String, version: Schema.String }); - // pnpm publish delegates to npm internals, which only honor per-registry auth - // configured in an .npmrc — `NPM_CONFIG_TOKEN` is not consulted. Write a temp - // .npmrc with `_authToken` for the verdaccio host and point npm at it via - // `npm_config_userconfig` so every publish call sees credentials. - const publishNpmrc = path.join(tmp.path, "publish.npmrc"); - await writeFile(publishNpmrc, `//localhost:${PORT}/:_authToken=dummy\n`); - const publishEnv = { ...process.env, npm_config_userconfig: publishNpmrc }; - - // Sync versions across all packages - console.log(`Syncing versions to ${version}...`); - await $`pnpm exec bun apps/cli/scripts/sync-versions.ts --version ${version}`.cwd(root).quiet(); - - console.log("Starting local npm registry..."); - await using registry = await startVerdaccio(configPath, PORT); - console.log(`Registry ready at ${registry.url}\n`); - - const platformPackages = ALL_PACKAGES.filter((p) => p !== "cli"); - console.log("Publishing platform packages..."); - for (const pkg of platformPackages) { - const pkgDir = path.join(root, "packages", pkg); - try { - await $`pnpm publish --registry ${registry.url} --tag ${tag} --no-git-checks` - .cwd(pkgDir) - .env(publishEnv); - console.log(` @supabase/${pkg}`); - } catch (e) { - if (await hasVerdaccioTarball(storageDir, pkg)) { - console.log(` @supabase/${pkg} (already present in local registry)`); - continue; +const runNpmTestEffect = ( + version: string, + tag: "latest" | "alpha" | "beta", +): Effect.Effect< + boolean, + NpmRegistryError | PlatformError, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const originals = yield* savePackageJsons(); + yield* Effect.addFinalizer(() => restorePackageJsons(originals).pipe(Effect.ignoreCause)); + + const tmpPath = yield* fs.makeTempDirectoryScoped({ prefix: "npm-smoke-" }); + const port = 4873; + const configPath = join(tmpPath, "config.yaml"); + const storageDir = join(tmpPath, "storage"); + const configLines = [ + `storage: ${storageDir}`, + "auth:", + " htpasswd:", + ` file: ${join(tmpPath, "htpasswd")}`, + " max_users: 100", + "uplinks:", + " npmjs:", + " url: https://registry.npmjs.org/", + "packages:", + ' "supabase":', + " access: $all", + " publish: $all", + ' "@supabase/*":', + " access: $all", + " publish: $all", + ' "**":', + " access: $all", + " publish: $all", + " proxy: npmjs", + "max_body_size: 200mb", + `listen: 0.0.0.0:${port}`, + "", + ]; + yield* writeFileString(configPath, configLines.join("\n")); + const publishNpmrc = join(tmpPath, "publish.npmrc"); + yield* writeFileString(publishNpmrc, `//localhost:${port}/:_authToken=dummy\n`); + + yield* Effect.log(`Syncing versions to ${version}...`); + const syncResult = yield* runCommand( + "pnpm", + ["exec", "bun", "apps/cli/scripts/sync-versions.ts", "--version", version], + { cwd: root }, + ); + if (syncResult.status !== 0) { + return yield* new NpmRegistryError({ message: syncResult.stderr || "version sync failed" }); } - throw e; - } - } - - // Inspect what Verdaccio actually received — directly answers whether - // `publishConfig.executableFiles` is being applied to the published tarball. - console.log("\nVerdaccio tarball contents (bin entries only):"); - for (const pkg of platformPackages) { - await inspectVerdaccioTarball(storageDir, pkg); - } - // Build and publish umbrella package - const cliDir = path.join(root, "apps", "cli"); - console.log("\nBuilding umbrella package shim..."); - await $`pnpm build:shim`.cwd(cliDir).quiet(); - - const cliPkgJson = await readFile(path.join(cliDir, "package.json"), "utf-8").then(JSON.parse); - const umbrellaName: string = cliPkgJson.name; - - console.log("Publishing umbrella package..."); - await $`pnpm publish --registry ${registry.url} --tag ${tag} --no-git-checks` - .cwd(cliDir) - .env(publishEnv); - console.log(` ${umbrellaName}\n`); - - console.log("Verdaccio umbrella tarball contents:"); - const umbrellaStorage = path.join(storageDir, umbrellaName); - try { - const files = await readdir(umbrellaStorage); - const tarball = files.find((f) => f.endsWith(".tgz")); - if (tarball) { - const listing = await $`tar -tvf ${path.join(umbrellaStorage, tarball)}`.text(); - for (const line of listing.split("\n").filter(Boolean)) { - console.log(` ${line.trim()}`); + yield* Effect.log("Starting local npm registry..."); + const registry = yield* startVerdaccio(configPath, port); + yield* Effect.log(`Registry ready at ${registry.url}\n`); + + const platformPackages = ALL_PACKAGES.filter((pkg) => pkg !== "cli"); + yield* Effect.log("Publishing platform packages..."); + for (const pkg of platformPackages) { + const pkgDir = join(root, "packages", pkg); + const publish = yield* runCommand( + "pnpm", + ["publish", "--registry", registry.url, "--tag", tag, "--no-git-checks"], + { + cwd: pkgDir, + env: { npm_config_userconfig: publishNpmrc }, + extendEnv: true, + }, + ); + if (publish.status !== 0 && !(yield* hasVerdaccioTarball(storageDir, pkg))) { + return yield* new NpmRegistryError({ + message: publish.stderr || `publishing @supabase/${pkg} failed`, + }); + } + yield* Effect.log( + publish.status === 0 + ? ` @supabase/${pkg}` + : ` @supabase/${pkg} (already present in local registry)`, + ); } - } else { - console.log(` <no .tgz under ${umbrellaStorage}>`); - } - } catch { - console.log(` <no umbrella tarball storage at ${umbrellaStorage}>`); - } - // Create test project - const testDir = path.join(tmp.path, "test-project"); - await mkdir(testDir); - await writeFile( - path.join(testDir, "package.json"), - JSON.stringify({ name: "test-npm-smoke", version: "0.0.0", private: true }), - ); - await writeFile( - path.join(testDir, ".npmrc"), - `registry=${registry.url}\n//localhost:${PORT}/:_authToken=dummy\n`, - ); + yield* Effect.log("\nVerdaccio tarball contents (bin entries only):"); + yield* Effect.forEach(platformPackages, (pkg) => inspectVerdaccioTarball(storageDir, pkg), { + discard: true, + }); + + const cliDir = join(root, "apps", "cli"); + yield* Effect.log("\nBuilding umbrella package shim..."); + const shim = yield* runCommand("pnpm", ["build:shim"], { cwd: cliDir, ignoreOutput: true }); + if (shim.status !== 0) { + return yield* new NpmRegistryError({ + message: shim.stderr || "building umbrella shim failed", + }); + } + const cliManifest = yield* Schema.decodeEffect(Schema.fromJsonString(PackageManifest))( + yield* readFileString(join(cliDir, "package.json")), + ).pipe( + Effect.mapError( + (cause) => new NpmRegistryError({ message: "CLI package JSON decoding failed", cause }), + ), + ); + yield* Effect.log("Publishing umbrella package..."); + const umbrellaPublish = yield* runCommand( + "pnpm", + ["publish", "--registry", registry.url, "--tag", tag, "--no-git-checks"], + { + cwd: cliDir, + env: { npm_config_userconfig: publishNpmrc }, + extendEnv: true, + }, + ); + if (umbrellaPublish.status !== 0) { + return yield* new NpmRegistryError({ + message: umbrellaPublish.stderr || "publishing umbrella package failed", + }); + } + yield* Effect.log(` ${cliManifest.name}\n`); + + yield* Effect.log("Verdaccio umbrella tarball contents:"); + const umbrellaStorage = join(storageDir, cliManifest.name); + const umbrellaFiles = yield* fs + .readDirectory(umbrellaStorage) + .pipe(Effect.orElseSucceed(() => [])); + const umbrellaTarball = umbrellaFiles.find((file) => file.endsWith(".tgz")); + if (umbrellaTarball !== undefined) { + const listing = yield* runCommand("tar", ["-tvf", join(umbrellaStorage, umbrellaTarball)]); + yield* Effect.forEach( + listing.stdout.split("\n").filter(Boolean), + (line) => Effect.log(` ${line.trim()}`), + { discard: true }, + ); + } else { + yield* Effect.log(` <no .tgz under ${umbrellaStorage}>`); + } - // Install. Pass --registry explicitly: in some environments (notably ones - // where pnpm has set `npm_config_*` env vars) those override the project - // .npmrc, and `npm install supabase` silently fetches from registry.npmjs.org - // instead — the test then accidentally exercises the published 2.x CLI rather - // than the umbrella we just packed. The CLI flag wins over both env vars and - // .npmrc, so it is the only resolution path that is actually safe here. - const installSpec = tag === "latest" ? umbrellaName : `${umbrellaName}@${tag}`; - console.log(`\nInstalling ${installSpec}...`); - await $`npm install --registry ${registry.url} ${installSpec}`.cwd(testDir); - - // Verify - console.log("\nVerifying..."); - const ext = process.platform === "win32" ? ".cmd" : ""; - const binPath = path.join(testDir, "node_modules", ".bin", `supabase${ext}`); - - await dumpInstalledTree(testDir, ext); - - const versionResult = await runCli(binPath, ["--version"]); - const hasValidVersion = - versionResult.exitCode === 0 && /^\d+\.\d+\.\d+/.test(versionResult.stdout); - - if (!hasValidVersion) { - console.log(`\n[verify] supabase --version FAILED:`); - console.log(` exit=${versionResult.exitCode}`); - console.log(` stdout=${JSON.stringify(versionResult.stdout)}`); - console.log(` stderr=${JSON.stringify(versionResult.stderr)}`); - - // Isolate "shim broken" vs "platform binary broken" by trying the - // platform binary directly. - const platformBin = await findPlatformBinary(testDir, ext); - if (platformBin) { - console.log(`\n[verify] retrying via platform binary: ${platformBin}`); - const direct = await runCli(platformBin, ["--version"]); - console.log(` exit=${direct.exitCode}`); - console.log(` stdout=${JSON.stringify(direct.stdout)}`); - console.log(` stderr=${JSON.stringify(direct.stderr)}`); - } else { - console.log(`\n[verify] no platform binary found under node_modules/@supabase/*/bin/`); - } - } + const testDir = join(tmpPath, "test-project"); + yield* fs.makeDirectory(testDir); + yield* writeFileString( + join(testDir, "package.json"), + yield* encodeJson({ name: "test-npm-smoke", version: "0.0.0", private: true }), + ); + yield* writeFileString( + join(testDir, ".npmrc"), + `registry=${registry.url}\n//localhost:${port}/:_authToken=dummy\n`, + ); + + const installSpec = tag === "latest" ? cliManifest.name : `${cliManifest.name}@${tag}`; + yield* Effect.log(`\nInstalling ${installSpec}...`); + const install = yield* runCommand( + "npm", + ["install", "--registry", registry.url, installSpec], + { + cwd: testDir, + }, + ); + if (install.status !== 0) { + return yield* new NpmRegistryError({ message: install.stderr || "npm install failed" }); + } - const shellCheck = await verifyExpectedShell(binPath); - const passed = hasValidVersion && shellCheck.passed; + yield* Effect.log("\nVerifying..."); + const ext = process.platform === "win32" ? ".cmd" : ""; + const binPath = join(testDir, "node_modules", ".bin", `supabase${ext}`); + yield* dumpInstalledTree(testDir, ext); + + const versionResult = yield* Effect.tryPromise({ + try: () => runCli(binPath, ["--version"]), + catch: (cause) => new NpmRegistryError({ message: "running installed CLI failed", cause }), + }); + const hasValidVersion = + versionResult.exitCode === 0 && /^\d+\.\d+\.\d+/.test(versionResult.stdout); + if (!hasValidVersion) { + yield* Effect.log("\n[verify] supabase --version FAILED:"); + yield* Effect.log(` exit=${versionResult.exitCode}`); + yield* Effect.log(` stdout=${versionResult.stdout}`); + yield* Effect.log(` stderr=${versionResult.stderr}`); + + const platformBin = yield* findPlatformBinary(testDir, ext); + if (Option.isSome(platformBin)) { + yield* Effect.log(`\n[verify] retrying via platform binary: ${platformBin.value}`); + const direct = yield* Effect.tryPromise({ + try: () => runCli(platformBin.value, ["--version"]), + catch: (cause) => + new NpmRegistryError({ message: "running platform CLI failed", cause }), + }); + yield* Effect.log(` exit=${direct.exitCode}`); + yield* Effect.log(` stdout=${direct.stdout}`); + yield* Effect.log(` stderr=${direct.stderr}`); + } else { + yield* Effect.log( + "\n[verify] no platform binary found under node_modules/@supabase/*/bin/", + ); + } + } - console.log( - `\n${passed ? "PASS" : "FAIL"} — supabase --version exit=${versionResult.exitCode} stdout=${JSON.stringify(versionResult.stdout)}`, + const shellCheck = yield* Effect.tryPromise({ + try: () => verifyExpectedShell(binPath), + catch: (cause) => new NpmRegistryError({ message: "shell verification failed", cause }), + }); + const passed = hasValidVersion && shellCheck.passed; + yield* Effect.log( + `\n${passed ? "PASS" : "FAIL"} — supabase --version exit=${versionResult.exitCode} stdout=${versionResult.stdout}`, + ); + yield* Effect.log(shellCheck.detail); + return passed; + }), ); - console.log(shellCheck.detail); - return passed; +export function runNpmTest( + version: string, + tag: "latest" | "alpha" | "beta" = "latest", +): Promise<boolean> { + return Effect.runPromise(runNpmTestEffect(version, tag).pipe(Effect.provide(BunServices.layer))); } diff --git a/apps/cli/tests/helpers/release-shell.ts b/apps/cli/tests/helpers/release-shell.ts index a45798b3ad..1c5430aeea 100644 --- a/apps/cli/tests/helpers/release-shell.ts +++ b/apps/cli/tests/helpers/release-shell.ts @@ -1,3 +1,9 @@ +import { BunServices } from "@effect/platform-bun"; +import { Effect, Stream } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type { PlatformError } from "effect/PlatformError"; + type ShellCheckResult = { readonly passed: boolean; readonly detail: string; @@ -9,33 +15,58 @@ export interface CliRunResult { readonly exitCode: number; } -export async function runCli(binPath: string, args: Array<string>): Promise<CliRunResult> { - const proc = Bun.spawn([binPath, ...args], { - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); +export const runCliEffect = ( + binPath: string, + args: ReadonlyArray<string>, +): Effect.Effect<CliRunResult, PlatformError, ChildProcessSpawner.ChildProcessSpawner> => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make(binPath, args, { + stdout: "pipe", + stderr: "pipe", + }), + ); + const result = yield* Effect.all( + { + stdout: Stream.mkString(Stream.decodeText(handle.stdout)), + stderr: Stream.mkString(Stream.decodeText(handle.stderr)), + exitCode: handle.exitCode, + }, + { concurrency: "unbounded" }, + ); + return { + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + exitCode: result.exitCode, + }; + }), + ); - return { - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode, - }; +/** Promise facade for the release smoke script's executable edge. */ +export function runCli(binPath: string, args: Array<string>): Promise<CliRunResult> { + return Effect.runPromise(runCliEffect(binPath, args).pipe(Effect.provide(BunServices.layer))); } -export async function verifyExpectedShell(binPath: string): Promise<ShellCheckResult> { - const result = await runCli(binPath, ["init", "--help"]); - const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); - const passed = result.exitCode === 0 && output.includes("init"); - return { - passed, - detail: passed - ? 'dispatch ok: "init --help" succeeded' - : `expected dispatch via "init --help", got exit=${result.exitCode}, stdout=${JSON.stringify(result.stdout)}, stderr=${JSON.stringify(result.stderr)}`, - }; +export const verifyExpectedShellEffect = ( + binPath: string, +): Effect.Effect<ShellCheckResult, PlatformError, ChildProcessSpawner.ChildProcessSpawner> => + Effect.gen(function* () { + const result = yield* runCliEffect(binPath, ["init", "--help"]); + const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); + const passed = result.exitCode === 0 && output.includes("init"); + return { + passed, + detail: passed + ? 'dispatch ok: "init --help" succeeded' + : `expected dispatch via "init --help", got exit=${result.exitCode}, stdout=${result.stdout}, stderr=${result.stderr}`, + }; + }); + +/** Promise facade for the release smoke script's executable edge. */ +export function verifyExpectedShell(binPath: string): Promise<ShellCheckResult> { + return Effect.runPromise( + verifyExpectedShellEffect(binPath).pipe(Effect.provide(BunServices.layer)), + ); } diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 450e8764f1..fb5a592117 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/crypto-random-uuid-in-effect, effecttsgo/global-date, effecttsgo/node-builtin-import -- the e2e stack harness owns foreign Docker/subprocess resources and unique host identities. import { BunServices } from "@effect/platform-bun"; import { Stack, @@ -190,10 +191,9 @@ export async function makeManagedStackFixture( if (running && ownerState === undefined) { const runtimeStack = stackService( info, - manager.recordLifecycle(ownership, { stackId, lifecycle: "stopped" }).pipe( - Effect.asVoid, - Effect.catch(() => Effect.void), - ), + manager + .recordLifecycle(ownership, { stackId, lifecycle: "stopped" }) + .pipe(Effect.asVoid, Effect.ignore), ); yield* supervisorLifecycle.publishStack(runtimeStack); yield* Deferred.await(daemonReady); @@ -202,7 +202,7 @@ export async function makeManagedStackFixture( } yield* Deferred.succeed(ready, void 0); yield* Effect.succeed(started); - yield* Effect.never; + return yield* Effect.never; }), ), ); diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index 8d571f89f7..049186306e 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-console, effecttsgo/global-date, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- e2e cleanup must inspect and terminate foreign OS processes after crashes. import { execFileSync } from "node:child_process"; import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; import path from "node:path"; @@ -212,10 +213,14 @@ async function removeProjectWithDocker(projectDir: string): Promise<boolean> { { stdio: ["ignore", "ignore", "pipe"], timeout: 30_000 }, ); } catch (error) { + const stderrValue = + error != null && typeof error === "object" && "stderr" in error ? error.stderr : undefined; const stderr = - error != null && typeof error === "object" && "stderr" in error - ? String((error as { stderr: unknown }).stderr ?? "").trim() - : ""; + typeof stderrValue === "string" + ? stderrValue.trim() + : stderrValue instanceof Uint8Array + ? new TextDecoder().decode(stderrValue).trim() + : ""; dockerErr = stderr || (error instanceof Error ? error.message : String(error)); } diff --git a/apps/cli/tests/live-global-setup.ts b/apps/cli/tests/live-global-setup.ts index 40b4d31c57..4e6e67d0aa 100644 --- a/apps/cli/tests/live-global-setup.ts +++ b/apps/cli/tests/live-global-setup.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Vitest requires Promise-based global setup hooks. import type { ProvidedContext } from "vitest"; import { makeApiClient } from "@supabase/api/effect"; diff --git a/apps/cli/tests/smoke-test-linux.ts b/apps/cli/tests/smoke-test-linux.ts index 4cf88235de..83ce5c443d 100644 --- a/apps/cli/tests/smoke-test-linux.ts +++ b/apps/cli/tests/smoke-test-linux.ts @@ -1,5 +1,7 @@ import { $ } from "bun"; -import path from "node:path"; +import { BunPath } from "@effect/platform-bun"; +import { Effect } from "effect"; +import * as EffectPath from "effect/Path"; import process from "node:process"; import { parseArgs } from "node:util"; import { describeError, runNpmTest } from "./helpers/npm-registry.ts"; @@ -12,14 +14,18 @@ const { values } = parseArgs({ }, }); +const { resolve, join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); +const log = (message: string): void => Effect.runSync(Effect.log(message)); +const logError = (message: string): void => Effect.runSync(Effect.logError(message)); + const version = values.version!; const tag = values.tag; if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { - console.error(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); + logError(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); process.exit(1); } -const root = path.resolve(import.meta.dir, "../../.."); -const distDir = path.join(root, "dist"); +const root = resolve(import.meta.dir, "../../.."); +const distDir = join(root, "dist"); const dispatchProbe = "supabase init --help 2>&1 | grep -q init"; @@ -32,35 +38,35 @@ const results: TestResult[] = []; // --- Native --- -console.log(`\n${"=".repeat(60)}`); -console.log("Native binary tests"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("Native binary tests"); +log("=".repeat(60)); { const arch = process.arch; // "x64" or "arm64" const name = `native-linux-${arch}`; - const binPath = path.join(root, "packages", `cli-linux-${arch}`, "bin", "supabase"); + const binPath = join(root, "packages", `cli-linux-${arch}`, "bin", "supabase"); - console.log(`[${name}] Running ${binPath} --version...`); + log(`[${name}] Running ${binPath} --version...`); try { const output = await $`${binPath} --version`.text(); const trimmed = output.trim(); const shellCheck = await verifyExpectedShell(binPath); const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); + log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + log(`[${name}] ${shellCheck.detail}`); results.push({ name, status: passed ? "pass" : "fail" }); } catch (e) { - console.log(`[${name}] FAIL —\n${describeError(e)}`); + log(`[${name}] FAIL —\n${describeError(e)}`); results.push({ name, status: "fail" }); } } // --- Docker --- -console.log(`\n${"=".repeat(60)}`); -console.log("Docker-based Linux package tests"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("Docker-based Linux package tests"); +log("=".repeat(60)); const hasDocker = await $`docker --version`.quiet().then( () => true, @@ -68,7 +74,7 @@ const hasDocker = await $`docker --version`.quiet().then( ); if (!hasDocker) { - console.log("[docker] SKIP — docker not found"); + log("[docker] SKIP — docker not found"); } else { interface DockerResult { name: string; @@ -76,39 +82,39 @@ if (!hasDocker) { output: string; } - async function runDockerTest( + function runDockerTest( name: string, image: string, platform: string, commands: string, ): Promise<DockerResult> { - console.log(`[${name}] Running...`); - for (let attempt = 1; attempt <= 2; attempt++) { - const result = - await $`docker run --rm --platform ${platform} -v ${distDir}:/dist:ro ${image} sh -c ${commands}` - .nothrow() - .quiet(); - const stdout = result.stdout.toString().trim(); - const stderr = result.stderr.toString().trim(); - if (result.exitCode === 0) { - const lastLine = stdout.split("\n").pop() ?? ""; - const passed = /^\d+\.\d+\.\d+/.test(lastLine); - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${lastLine}`); - if (!passed && stderr) console.log(`[${name}] stderr: ${stderr}`); - return { name, passed, output: stdout }; - } - // Exit 125 is a docker daemon / container-start error, not a container - // exit code. Retry once before giving up. - if (result.exitCode === 125 && attempt === 1) { - console.log(`[${name}] docker exit 125, retrying once. stderr: ${stderr}`); - continue; - } - console.log(`[${name}] FAIL — exit ${result.exitCode}`); - if (stderr) console.log(`[${name}] stderr: ${stderr}`); - if (stdout) console.log(`[${name}] stdout: ${stdout}`); - return { name, passed: false, output: `${stdout}\n${stderr}`.trim() }; - } - return { name, passed: false, output: "unreachable" }; + log(`[${name}] Running...`); + const runAttempt = (attempt: number): Promise<DockerResult> => + $`docker run --rm --platform ${platform} -v ${distDir}:/dist:ro ${image} sh -c ${commands}` + .nothrow() + .quiet() + .then((result) => { + const stdout = result.stdout.toString().trim(); + const stderr = result.stderr.toString().trim(); + if (result.exitCode === 0) { + const lastLine = stdout.split("\n").pop() ?? ""; + const passed = /^\d+\.\d+\.\d+/.test(lastLine); + log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${lastLine}`); + if (!passed && stderr) log(`[${name}] stderr: ${stderr}`); + return { name, passed, output: stdout }; + } + // Exit 125 is a docker daemon / container-start error, not a container + // exit code. Retry once before giving up. + if (result.exitCode === 125 && attempt === 1) { + log(`[${name}] docker exit 125, retrying once. stderr: ${stderr}`); + return runAttempt(2); + } + log(`[${name}] FAIL — exit ${result.exitCode}`); + if (stderr) log(`[${name}] stderr: ${stderr}`); + if (stdout) log(`[${name}] stdout: ${stdout}`); + return { name, passed: false, output: `${stdout}\n${stderr}`.trim() }; + }); + return runAttempt(1); } const jobs: Promise<DockerResult>[] = []; @@ -161,32 +167,32 @@ if (!hasDocker) { // --- npm --- -console.log(`\n${"=".repeat(60)}`); -console.log("npm (Verdaccio) test"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("npm (Verdaccio) test"); +log("=".repeat(60)); try { const npmPassed = await runNpmTest(version, tag); results.push({ name: "npm", status: npmPassed ? "pass" : "fail" }); } catch (e) { - console.error(`[npm] Error:\n${describeError(e)}`); + logError(`[npm] Error:\n${describeError(e)}`); results.push({ name: "npm", status: "fail" }); } // --- Summary --- -console.log(`\n${"=".repeat(60)}`); -console.log("Linux Smoke Test Summary"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("Linux Smoke Test Summary"); +log("=".repeat(60)); for (const r of results) { - console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); + log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); } const passed = results.filter((r) => r.status === "pass").length; const failed = results.filter((r) => r.status === "fail").length; -console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); +log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); if (failed > 0) { process.exit(1); diff --git a/apps/cli/tests/smoke-test-macos.ts b/apps/cli/tests/smoke-test-macos.ts index 5dc50b3ade..3319afec2f 100644 --- a/apps/cli/tests/smoke-test-macos.ts +++ b/apps/cli/tests/smoke-test-macos.ts @@ -1,7 +1,7 @@ import { $ } from "bun"; -import { existsSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; -import path from "node:path"; +import { BunPath, BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem } from "effect"; +import * as EffectPath from "effect/Path"; import process from "node:process"; import { parseArgs } from "node:util"; import { verifyMacSignature } from "./helpers/macos-signature.ts"; @@ -15,13 +15,31 @@ const { values } = parseArgs({ }, }); +const { resolve, join } = Effect.runSync(EffectPath.Path.pipe(Effect.provide(BunPath.layer))); +const log = (message: string): void => Effect.runSync(Effect.log(message)); +const logError = (message: string): void => Effect.runSync(Effect.logError(message)); +const exists = (path: string): Promise<boolean> => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }).pipe(Effect.provide(BunServices.layer)), + ); +const makeDirectory = (path: string): Promise<void> => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(path, { recursive: true }); + }).pipe(Effect.provide(BunServices.layer)), + ); + const version = values.version!; const tag = values.tag; if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { - console.error(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); + logError(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); process.exit(1); } -const root = path.resolve(import.meta.dir, "../../.."); +const root = resolve(import.meta.dir, "../../.."); interface TestResult { name: string; @@ -32,26 +50,26 @@ const results: TestResult[] = []; // --- Native --- -console.log(`\n${"=".repeat(60)}`); -console.log("Native binary tests"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("Native binary tests"); +log("=".repeat(60)); { const arch = process.arch; // "arm64" or "x64" const name = `native-darwin-${arch}`; - const binPath = path.join(root, "packages", `cli-darwin-${arch}`, "bin", "supabase"); + const binPath = join(root, "packages", `cli-darwin-${arch}`, "bin", "supabase"); - console.log(`[${name}] Running ${binPath} --version...`); + log(`[${name}] Running ${binPath} --version...`); try { const output = await $`${binPath} --version`.text(); const trimmed = output.trim(); const shellCheck = await verifyExpectedShell(binPath); const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); + log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); + log(`[${name}] ${shellCheck.detail}`); results.push({ name, status: passed ? "pass" : "fail" }); } catch (e) { - console.log(`[${name}] FAIL —\n${describeError(e)}`); + log(`[${name}] FAIL —\n${describeError(e)}`); results.push({ name, status: "fail" }); } } @@ -60,22 +78,22 @@ console.log("=".repeat(60)); { const arch = process.arch; // "arm64" or "x64" - const binDir = path.join(root, "packages", `cli-darwin-${arch}`, "bin"); + const binDir = join(root, "packages", `cli-darwin-${arch}`, "bin"); const binaries = ["supabase"]; - if (existsSync(path.join(binDir, "supabase-go"))) { + if (await exists(join(binDir, "supabase-go"))) { binaries.push("supabase-go"); } for (const binary of binaries) { const name = `native-darwin-${arch}-signature-${binary}`; - const binPath = path.join(binDir, binary); - console.log(`[${name}] Verifying signature of ${binPath}...`); + const binPath = join(binDir, binary); + log(`[${name}] Verifying signature of ${binPath}...`); try { const sig = await verifyMacSignature(binPath); - console.log(`[${name}] ${sig.passed ? "PASS" : "FAIL"} — ${sig.detail}`); + log(`[${name}] ${sig.passed ? "PASS" : "FAIL"} — ${sig.detail}`); results.push({ name, status: sig.passed ? "pass" : "fail" }); } catch (e) { - console.log(`[${name}] FAIL —\n${describeError(e)}`); + log(`[${name}] FAIL —\n${describeError(e)}`); results.push({ name, status: "fail" }); } } @@ -83,23 +101,23 @@ console.log("=".repeat(60)); // --- npm --- -console.log(`\n${"=".repeat(60)}`); -console.log("npm (Verdaccio) test"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("npm (Verdaccio) test"); +log("=".repeat(60)); try { const npmPassed = await runNpmTest(version, tag); results.push({ name: "npm", status: npmPassed ? "pass" : "fail" }); } catch (e) { - console.error(`[npm] Error:\n${describeError(e)}`); + logError(`[npm] Error:\n${describeError(e)}`); results.push({ name: "npm", status: "fail" }); } // --- Brew --- -console.log(`\n${"=".repeat(60)}`); -console.log("Homebrew test"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("Homebrew test"); +log("=".repeat(60)); const hasBrew = await $`brew --version`.quiet().then( () => true, @@ -107,22 +125,22 @@ const hasBrew = await $`brew --version`.quiet().then( ); if (!hasBrew) { - console.log("[brew] SKIP — brew not found"); + log("[brew] SKIP — brew not found"); } else { try { // Generate the formula with local file:// URLs - console.log("Generating Homebrew formula..."); + log("Generating Homebrew formula..."); await $`bun run apps/cli/scripts/update-homebrew.ts --version ${version} --local`.cwd(root); // Create a local git-backed tap await using tap = await createTmpDir("brew-smoke-"); - await mkdir(path.join(tap.path, "Formula")); - await $`cp ${path.join(root, "dist", "supabase.rb")} ${path.join(tap.path, "Formula", "supabase.rb")}`; + await makeDirectory(join(tap.path, "Formula")); + await $`cp ${join(root, "dist", "supabase.rb")} ${join(tap.path, "Formula", "supabase.rb")}`; await $`git -C ${tap.path} init`.quiet(); await $`git -C ${tap.path} add .`.quiet(); await $`git -C ${tap.path} commit -m init`.quiet(); - console.log("Installing via Homebrew..."); + log("Installing via Homebrew..."); await $`brew tap --force supabase/test-tap ${tap.path}`; try { @@ -133,33 +151,33 @@ if (!hasBrew) { const shellCheck = await verifyExpectedShell("supabase"); const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[brew] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`); - console.log(`[brew] ${shellCheck.detail}`); + log(`[brew] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`); + log(`[brew] ${shellCheck.detail}`); results.push({ name: "brew", status: passed ? "pass" : "fail" }); } finally { await $`brew uninstall supabase`.nothrow(); await $`brew untap supabase/test-tap`.nothrow(); } } catch (e) { - console.error(`[brew] Error:\n${describeError(e)}`); + logError(`[brew] Error:\n${describeError(e)}`); results.push({ name: "brew", status: "fail" }); } } // --- Summary --- -console.log(`\n${"=".repeat(60)}`); -console.log("macOS Smoke Test Summary"); -console.log("=".repeat(60)); +log(`\n${"=".repeat(60)}`); +log("macOS Smoke Test Summary"); +log("=".repeat(60)); for (const r of results) { - console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); + log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); } const passed = results.filter((r) => r.status === "pass").length; const failed = results.filter((r) => r.status === "fail").length; -console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); +log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); if (failed > 0) { process.exit(1); diff --git a/apps/cli/tests/smoke-test-windows.ts b/apps/cli/tests/smoke-test-windows.ts index 8b4c26d63c..add9642b05 100644 --- a/apps/cli/tests/smoke-test-windows.ts +++ b/apps/cli/tests/smoke-test-windows.ts @@ -1,11 +1,28 @@ import { $ } from "bun"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Path } from "effect"; import { verifyExpectedShell } from "./helpers/release-shell.ts"; +class SmokeError extends Data.TaggedError("SmokeError")<{ + readonly operation: string; + readonly cause: unknown; +}> {} + +const errorMessage = (error: unknown) => + error instanceof SmokeError + ? `${error.operation}: ${error.cause instanceof Error ? error.cause.message : String(error.cause)}` + : error instanceof Error + ? error.message + : String(error); + +const runForeign = <A>(operation: string, promise: () => PromiseLike<A>) => + Effect.tryPromise({ + try: promise, + catch: (cause) => new SmokeError({ operation, cause }), + }); + const { values } = parseArgs({ options: { version: { type: "string", default: "0.0.1-smoke" }, @@ -13,141 +30,172 @@ const { values } = parseArgs({ }, }); -const version = values.version!; +const version = values.version ?? "0.0.1-smoke"; const tag = values.tag; -if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { - console.error(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); - process.exit(1); -} -const root = path.resolve(import.meta.dir, "../../.."); - -async function gitBashPath(filePath: string) { - return process.platform === "win32" ? (await $`cygpath -u ${filePath}`.text()).trim() : filePath; -} interface TestResult { - name: string; - status: "pass" | "fail"; + readonly name: string; + readonly status: "pass" | "fail"; } -const results: TestResult[] = []; - -// --- Native --- - -console.log(`\n${"=".repeat(60)}`); -console.log("Native binary tests"); -console.log("=".repeat(60)); - -const arch = process.arch === "arm64" ? "arm64" : "x64"; - -{ - const name = `native-windows-${arch}`; - const binPath = path.join(root, "packages", `cli-windows-${arch}`, "bin", "supabase.exe"); - - console.log(`[${name}] Running ${binPath} --version...`); - try { - const output = await $`${binPath} --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell(binPath); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); - results.push({ name, status: passed ? "pass" : "fail" }); - } catch (e) { - console.log(`[${name}] FAIL — ${e}`); - results.push({ name, status: "fail" }); +const main = Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const root = path.resolve(import.meta.dir, "../../.."); + const results: Array<TestResult> = []; + + const log = (message: string) => + Effect.sync(() => { + process.stdout.write(`${message}\n`); + }); + const logError = (message: string) => + Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); + const gitBashPath = (filePath: string) => + process.platform === "win32" + ? runForeign("cygpath", () => $`cygpath -u ${filePath}`.text()).pipe( + Effect.map((value) => value.trim()), + ) + : Effect.succeed(filePath); + + const runCase = <A extends { readonly passed: boolean; readonly detail: string }>( + name: string, + effect: Effect.Effect<A, SmokeError>, + ) => + Effect.gen(function* () { + const result = yield* effect.pipe( + Effect.match({ + onFailure: (error) => ({ + passed: false, + detail: `${error.operation}: ${error.cause instanceof Error ? error.cause.message : String(error.cause)}`, + }), + onSuccess: (value) => value, + }), + ); + yield* log(`[${name}] ${result.passed ? "PASS" : "FAIL"} — ${result.detail}`); + results.push({ name, status: result.passed ? "pass" : "fail" }); + }); + + const checkBinary = (binPath: string) => + Effect.gen(function* () { + const output = yield* runForeign("binary --version", () => $`${binPath} --version`.text()); + const trimmed = output.trim(); + const shellCheck = yield* runForeign("verify expected shell", () => + verifyExpectedShell(binPath), + ); + return { + passed: /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed, + detail: `${trimmed} (${shellCheck.detail})`, + }; + }); + + if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { + yield* logError(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); + return 1; } -} -// --- Release tarball --- + yield* log(`\n${"=".repeat(60)}`); + yield* log("Native binary tests"); + yield* log("=".repeat(60)); + + const arch = process.arch === "arm64" ? "arm64" : "x64"; + const nativeName = `native-windows-${arch}`; + const nativePath = path.join(root, "packages", `cli-windows-${arch}`, "bin", "supabase.exe"); + yield* log(`[${nativeName}] Running ${nativePath} --version...`); + yield* runCase(nativeName, checkBinary(nativePath)); -console.log(`\n${"=".repeat(60)}`); -console.log("Release tarball test"); -console.log("=".repeat(60)); + yield* log(`\n${"=".repeat(60)}`); + yield* log("Release tarball test"); + yield* log("=".repeat(60)); -{ const archiveArch = arch === "arm64" ? "arm64" : "amd64"; - const name = `windows-${archiveArch}-tarball`; + const archiveName = `windows-${archiveArch}-tarball`; const archivePath = path.join(root, "dist", `supabase_${version}_windows_${archiveArch}.tar.gz`); - const extractDir = await mkdtemp(path.join(tmpdir(), "supabase-windows-tarball-")); - - console.log(`[${name}] Extracting ${archivePath}...`); - try { - await $`tar -xzf ${await gitBashPath(archivePath)} -C ${await gitBashPath(extractDir)}`; - const binPath = path.join(extractDir, "supabase.exe"); - const output = await $`${binPath} --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell(binPath); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - - console.log(`[${name}] ${passed ? "PASS" : "FAIL"} — ${trimmed}`); - console.log(`[${name}] ${shellCheck.detail}`); - results.push({ name, status: passed ? "pass" : "fail" }); - } catch (e) { - console.error(`[${name}] Error: ${e}`); - results.push({ name, status: "fail" }); - } finally { - await rm(extractDir, { recursive: true, force: true }); + const extractDir = yield* Effect.acquireRelease( + fileSystem.makeTempDirectory({ prefix: "supabase-windows-tarball-" }), + (directory) => + fileSystem.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore), + ); + yield* log(`[${archiveName}] Extracting ${archivePath}...`); + yield* runCase( + archiveName, + Effect.gen(function* () { + const archive = yield* gitBashPath(archivePath); + const destination = yield* gitBashPath(extractDir); + yield* runForeign("tar extraction", () => $`tar -xzf ${archive} -C ${destination}`); + const binPath = path.join(extractDir, "supabase.exe"); + return yield* checkBinary(binPath); + }), + ); + + yield* log(`\n${"=".repeat(60)}`); + yield* log("Scoop test"); + yield* log("=".repeat(60)); + + const hasScoop = yield* runForeign("scoop --version", () => $`scoop --version`.quiet()).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!hasScoop) { + yield* log("[scoop] SKIP — scoop not found"); + } else { + const manifest = path.join(root, "dist", "supabase.json"); + yield* Effect.acquireUseRelease( + Effect.gen(function* () { + yield* log("Generating Scoop manifest..."); + yield* runForeign("generate Scoop manifest", () => + $`bun run apps/cli/scripts/update-scoop.ts --version ${version} --local`.cwd(root), + ); + yield* log("Installing via Scoop..."); + yield* runForeign("scoop install", () => $`scoop install ${manifest}`); + }), + () => + runCase( + "scoop", + Effect.gen(function* () { + const output = yield* runForeign("supabase --version", () => + $`supabase --version`.text(), + ); + const trimmed = output.trim(); + const shellCheck = yield* runForeign("verify expected shell", () => + verifyExpectedShell("supabase"), + ); + return { + passed: /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed, + detail: `${trimmed} (${shellCheck.detail})`, + }; + }), + ), + () => + runForeign("scoop uninstall", () => $`scoop uninstall supabase`.nothrow()).pipe( + Effect.asVoid, + ), + ); } -} - -// --- Scoop --- -console.log(`\n${"=".repeat(60)}`); -console.log("Scoop test"); -console.log("=".repeat(60)); - -const hasScoop = await $`scoop --version`.quiet().then( - () => true, - () => false, -); - -if (!hasScoop) { - console.log("[scoop] SKIP — scoop not found"); -} else { - const manifest = path.join(root, "dist", "supabase.json"); - - try { - // Generate the manifest with local file:/// URLs - console.log("Generating Scoop manifest..."); - await $`bun run apps/cli/scripts/update-scoop.ts --version ${version} --local`.cwd(root); - - console.log("Installing via Scoop..."); - await $`scoop install ${manifest}`; - - try { - const output = await $`supabase --version`.text(); - const trimmed = output.trim(); - const shellCheck = await verifyExpectedShell("supabase"); - const passed = /^\d+\.\d+\.\d+/.test(trimmed) && shellCheck.passed; - - console.log(`[scoop] ${passed ? "PASS" : "FAIL"} — supabase --version: ${trimmed}`); - console.log(`[scoop] ${shellCheck.detail}`); - results.push({ name: "scoop", status: passed ? "pass" : "fail" }); - } finally { - await $`scoop uninstall supabase`.nothrow(); - } - } catch (e) { - console.error(`[scoop] Error: ${e}`); - results.push({ name: "scoop", status: "fail" }); + yield* log(`\n${"=".repeat(60)}`); + yield* log("Windows Smoke Test Summary"); + yield* log("=".repeat(60)); + for (const result of results) { + yield* log(` ${result.status === "pass" ? "PASS" : "FAIL"} ${result.name}`); } -} - -// --- Summary --- - -console.log(`\n${"=".repeat(60)}`); -console.log("Windows Smoke Test Summary"); -console.log("=".repeat(60)); - -for (const r of results) { - console.log(` ${r.status === "pass" ? "PASS" : "FAIL"} ${r.name}`); -} - -const passed = results.filter((r) => r.status === "pass").length; -const failed = results.filter((r) => r.status === "fail").length; - -console.log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); + const passed = results.filter((result) => result.status === "pass").length; + const failed = results.filter((result) => result.status === "fail").length; + yield* log(`\n${passed} passed, ${failed} failed out of ${results.length} tests`); + return failed > 0 ? 1 : 0; +}); -if (failed > 0) { - process.exit(1); -} +Effect.runPromise(Effect.scoped(main).pipe(Effect.provide(BunServices.layer))).then( + (exitCode) => { + process.exitCode = exitCode; + }, + (error: unknown) => { + Effect.runSync( + Effect.sync(() => { + process.stderr.write(`${errorMessage(error)}\n`); + }), + ); + process.exitCode = 1; + }, +); diff --git a/apps/cli/tests/smoke-test.ts b/apps/cli/tests/smoke-test.ts index 577ba12201..7fe2e1620a 100644 --- a/apps/cli/tests/smoke-test.ts +++ b/apps/cli/tests/smoke-test.ts @@ -1,6 +1,7 @@ -import path from "node:path"; import process from "node:process"; import { parseArgs } from "node:util"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Path } from "effect"; const { values } = parseArgs({ options: { @@ -12,7 +13,9 @@ const { values } = parseArgs({ const version = values.version!; const tag = values.tag; if (tag !== "latest" && tag !== "alpha" && tag !== "beta") { - console.error(`Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".`); + process.stderr.write( + `Invalid --tag value: ${String(tag)}. Expected "latest", "alpha", or "beta".\n`, + ); process.exit(1); } const testsDir = import.meta.dir; @@ -25,12 +28,17 @@ const platformScripts: Record<string, string> = { const script = platformScripts[process.platform]; if (!script) { - console.error(`Unsupported platform: ${process.platform}`); + process.stderr.write(`Unsupported platform: ${process.platform}\n`); process.exit(1); } -const scriptPath = path.join(testsDir, script); -console.log(`Detected platform: ${process.platform} — running ${script}\n`); +const scriptPath = Effect.runSync( + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(testsDir, script); + }).pipe(Effect.provide(BunServices.layer)), +); +process.stdout.write(`Detected platform: ${process.platform} — running ${script}\n\n`); const proc = Bun.spawn(["bun", "run", scriptPath, "--version", version, "--tag", tag], { stdout: "inherit", diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index fcfa681541..309aae5e3c 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { defineConfig } from "vitest/config"; function dockerfileTextPlugin() { @@ -10,7 +9,9 @@ function dockerfileTextPlugin() { return undefined; } - return `export default ${JSON.stringify(readFileSync(filePath, "utf8"))};`; + return Bun.file(filePath) + .text() + .then((text) => `export default ${JSON.stringify(text)};`); }, }; } diff --git a/docs/nx-inference-plugins.md b/docs/nx-inference-plugins.md index d243a2e551..7e14d5ae0b 100644 --- a/docs/nx-inference-plugins.md +++ b/docs/nx-inference-plugins.md @@ -52,16 +52,16 @@ Infers `lint:check` and `lint:fix` targets for any workspace package that has `o **Detection signal:** `package.json` must have `"oxlint"` under `devDependencies`. -**Per-project config:** an optional `"oxlint": { "typeAware": true }` key in `package.json` enables `--type-aware` linting for that project. Projects without this key get plain `--deny-warnings` linting. +The root `.oxlintrc.json` extends Effect's recommended preset from `@effect/tsgo`. The root `prepare` script patches Oxlint with the Effect TypeScript-Go integration after installation, without patching TypeScript. **Inferred targets:** | Target | Command | Cached | Inputs | |--------|---------|--------|--------| -| `lint:check` | `oxlint [--type-aware] --deny-warnings` | Yes | `default`, `oxlint` package version | -| `lint:fix` | `oxlint [--type-aware] --deny-warnings --fix` | No | — | +| `lint:check` | `oxlint --deny-warnings` | Yes | `default`, root `.oxlintrc.json`, `oxlint`, `oxlint-tsgolint`, and `@effect/tsgo` package versions | +| `lint:fix` | `oxlint --deny-warnings --fix` | No | — | -Currently `packages/api` is the only project with `"oxlint": { "typeAware": true }`. +The recommended Effect preset enables type-aware linting and the `effecttsgo` plugin for every inferred Oxlint target. ### `typescript.plugin.ts` diff --git a/package.json b/package.json index f51677da66..faf3a7e162 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ } }, "scripts": { + "prepare": "effect-tsgo patch --no-typescript --oxlint", "test:core": "nx run-many -t test:unit test:integration --coverage.enabled", "test:e2e": "nx run-many -t test:e2e", "affected:test:e2e": "nx affected -t test:e2e", @@ -20,6 +21,7 @@ }, "packageManager": "pnpm@11.4.0", "devDependencies": { + "@effect/tsgo": "catalog:", "nx": "catalog:", "oxfmt": "catalog:", "oxlint": "catalog:", diff --git a/packages/api/package.json b/packages/api/package.json index 486bfa5243..874d629314 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -72,8 +72,5 @@ ] } } - }, - "oxlint": { - "typeAware": true } } diff --git a/packages/api/scripts/download-openapi.ts b/packages/api/scripts/download-openapi.ts index 521dc83929..988c2e193a 100644 --- a/packages/api/scripts/download-openapi.ts +++ b/packages/api/scripts/download-openapi.ts @@ -1,13 +1,45 @@ #!/usr/bin/env bun -import { readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { BunServices } from "@effect/platform-bun"; +import { Config, Data, Effect, FileSystem, Layer, Option, Path } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as Schema from "effect/Schema"; 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 SCRIPT_DIR_URL = new URL(".", import.meta.url); +const OPENAPI_SPEC_URL = new URL("../src/generated/openapi.json", SCRIPT_DIR_URL); +const OPENAPI_OVERRIDES_URL = new URL("./openapi-overrides.json", SCRIPT_DIR_URL); +const OPENAPI_SOURCE_URL = new URL("./openapi-source.json", SCRIPT_DIR_URL); + +const SCRIPT_LAYERS = Layer.mergeAll(BunServices.layer, FetchHttpClient.layer); + +const runScript = <A, E extends Error>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient>, +): Promise<A> => Effect.runPromise(Effect.orDie(Effect.provide(effect, SCRIPT_LAYERS))); + +type ScriptPaths = { + readonly openApiSpec: string; + readonly openApiOverrides: string; + readonly openApiSource: string; +}; + +const resolveScriptPaths = Effect.gen(function* () { + const path = yield* Path.Path; + return { + openApiSpec: yield* path.fromFileUrl(OPENAPI_SPEC_URL), + openApiOverrides: yield* path.fromFileUrl(OPENAPI_OVERRIDES_URL), + openApiSource: yield* path.fromFileUrl(OPENAPI_SOURCE_URL), + } satisfies ScriptPaths; +}); + +class OpenApiDownloadError extends Data.TaggedError("OpenApiDownloadError")<{ + readonly message: string; +}> { + constructor(message: string) { + super({ message }); + } +} const OPENAPI_DOCUMENT_VERSIONS = ["v1", "v2"] as const; type OpenApiDocumentVersion = (typeof OPENAPI_DOCUMENT_VERSIONS)[number]; @@ -232,13 +264,17 @@ export function applyOpenApiOverrides( return document; } -async function loadOpenApiOverrides(): Promise<ReadonlyArray<unknown>> { - const parsed = JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8")); - if (!Array.isArray(parsed)) { - throw new Error("OpenAPI overrides file must contain a JSON Patch array."); - } - return parsed; -} +const loadOpenApiOverrides = (paths: ScriptPaths) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(paths.openApiOverrides), + ); + if (!Array.isArray(parsed)) { + throw new Error("OpenAPI overrides file must contain a JSON Patch array."); + } + return parsed; + }); function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { if (!isRecord(value) || typeof value.baseUrl !== "string") { @@ -246,27 +282,28 @@ function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { } } -async function loadPinnedBaseUrl(): Promise<string | undefined> { - let raw: string; - try { - raw = await readFile(OPENAPI_SOURCE_PATH, "utf8"); - } catch (error) { - // 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") { +const loadPinnedBaseUrl = (paths: ScriptPaths) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(paths.openApiSource))) { return undefined; } - throw error; - } - const parsed = JSON.parse(raw); - assertOpenApiSource(parsed); - return parsed.baseUrl; -} - -async function writeOpenApiSource(baseUrl: string): Promise<void> { - const source: OpenApiSource = { baseUrl }; - await writeFile(OPENAPI_SOURCE_PATH, `${JSON.stringify(source, null, 2)}\n`); -} + const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* fs.readFileString(paths.openApiSource), + ); + assertOpenApiSource(parsed); + return parsed.baseUrl; + }); + +const writeOpenApiSource = (paths: ScriptPaths, baseUrl: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const source: OpenApiSource = { baseUrl }; + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 }))( + source, + ); + yield* fs.writeFileString(paths.openApiSource, `${encoded}\n`); + }); export function resolveOpenApiBaseUrl({ envBaseUrl, @@ -280,7 +317,7 @@ export function resolveOpenApiBaseUrl({ } export function resolveOpenApiSpecUrl( - baseUrl = process.env.SUPABASE_API_URL, + baseUrl?: string, version: OpenApiDocumentVersion = "v1", ): string { const normalizedBaseUrl = resolveOpenApiBaseUrl({ envBaseUrl: baseUrl }); @@ -408,8 +445,9 @@ export function mergeOpenApiDocuments( // v2 document currently has 20 webhook operations sharing just 2 duplicated // operationIds, and the overrides remove those paths. Validating before // overrides were applied would abort every production regeneration. -export function assertMergedOpenApiDocument(document: OpenApiDocument): void { +export function assertMergedOpenApiDocument(document: OpenApiDocument): ReadonlyArray<string> { const operationClaims = new Map<string, Array<string>>(); + const warnings: Array<string> = []; for (const [pathKey, pathValue] of Object.entries(document.paths)) { if (!isRecord(pathValue)) { @@ -426,7 +464,7 @@ 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.`); + warnings.push(`OpenAPI operation ${label} has no operationId; generate.ts will skip it.`); continue; } @@ -457,46 +495,63 @@ export function assertMergedOpenApiDocument(document: OpenApiDocument): void { ); } } + return warnings; } -export async function downloadOpenApiSpec(): Promise<void> { - const envBaseUrl = process.env.SUPABASE_API_URL; - // The sidecar is consulted only when the environment does not 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 baseUrl = resolveOpenApiBaseUrl({ envBaseUrl, pinnedBaseUrl }); - 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); - - // Hard-fail on a missing document instead of tolerating it: a 404 on - // /api/v2-json would silently delete the whole v2 namespace from the - // generated client, and the hourly regeneration sync would auto-merge - // that deletion without anyone noticing. - if (!response.ok) { - throw new Error(`Failed to download OpenAPI spec from ${url}: ${response.status}`); +const downloadOpenApiDocument = (url: string) => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const response = yield* client.get(url); + if (response.status < 200 || response.status >= 300) { + return yield* new OpenApiDownloadError( + `Failed to download OpenAPI spec from ${url}: ${response.status}`, + ); } - - const document = await response.json(); + const document = yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response); assertOpenApiDocument(document); - documents.push({ version, document }); - } + return document; + }); + +const downloadOpenApiSpecEffect = (envBaseUrl?: string) => + Effect.gen(function* () { + const paths = yield* resolveScriptPaths; + // The sidecar is consulted only when the environment does not override it, + // so an explicit SUPABASE_API_URL works even when the pin is absent or + // malformed. + const pinnedBaseUrl = envBaseUrl === undefined ? yield* loadPinnedBaseUrl(paths) : undefined; + const baseUrl = resolveOpenApiBaseUrl({ envBaseUrl, pinnedBaseUrl }); + yield* Effect.log(`Resolved OpenAPI base URL: ${baseUrl}`); + + const documents: Array<{ + readonly version: OpenApiDocumentVersion; + readonly document: OpenApiDocument; + }> = []; + for (const { version, url } of resolveOpenApiSpecUrls(baseUrl)) { + yield* Effect.log(`Fetching ${version} OpenAPI document from ${url}`); + documents.push({ version, document: yield* downloadOpenApiDocument(url) }); + } - const mergedDocument = mergeOpenApiDocuments(documents); - applyOpenApiOverrides(mergedDocument, await loadOpenApiOverrides()); - assertMergedOpenApiDocument(mergedDocument); + const mergedDocument = mergeOpenApiDocuments(documents); + applyOpenApiOverrides(mergedDocument, yield* loadOpenApiOverrides(paths)); + for (const warning of assertMergedOpenApiDocument(mergedDocument)) { + yield* Effect.logWarning(warning); + } - await writeFile(OPENAPI_SPEC_PATH, `${JSON.stringify(mergedDocument, null, 2)}\n`); - await writeOpenApiSource(baseUrl); -} + const fs = yield* FileSystem.FileSystem; + const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown, { space: 2 }))( + mergedDocument, + ); + yield* fs.writeFileString(paths.openApiSpec, `${encoded}\n`); + yield* writeOpenApiSource(paths, baseUrl); + }); if (import.meta.main) { - await downloadOpenApiSpec(); + const program = Effect.gen(function* () { + const envBaseUrl = yield* Config.option(Config.string("SUPABASE_API_URL")); + yield* downloadOpenApiSpecEffect(Option.getOrUndefined(envBaseUrl)); + }); + runScript(program).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); } diff --git a/packages/api/scripts/download-openapi.unit.test.ts b/packages/api/scripts/download-openapi.unit.test.ts index a7521eb899..9df3832f8a 100644 --- a/packages/api/scripts/download-openapi.unit.test.ts +++ b/packages/api/scripts/download-openapi.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { describe, expect, test } from "vitest"; import { applyOpenApiOverrides, @@ -312,14 +312,9 @@ describe("download-openapi", () => { test("warns instead of throwing when an operation has no operationId", () => { const document = { paths: { "/v1/a": { get: {} } } }; - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - - expect(() => assertMergedOpenApiDocument(document)).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith( + expect(assertMergedOpenApiDocument(document)).toEqual([ "OpenAPI operation GET /v1/a has no operationId; generate.ts will skip it.", - ); - - warnSpy.mockRestore(); + ]); }); test("applyOpenApiOverrides tolerantly removes JSON pointers that no longer exist", () => { diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index 24c8a5348b..f3646aea8f 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun -import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import * as Arr from "effect/Array"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; import * as JsonSchema from "effect/JsonSchema"; +import * as Schema from "effect/Schema"; import * as SchemaRepresentation from "effect/SchemaRepresentation"; +import openApiSnapshot from "../src/generated/openapi.json" with { type: "json" }; type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD"; type OpenApiHttpMethod = Lowercase<HttpMethod>; @@ -135,19 +135,20 @@ const httpMethods: Record<OpenApiHttpMethod, HttpMethod> = { 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 SOURCE_SPEC_URL = new URL("../src/generated/openapi.json", import.meta.url); +const GENERATED_DIR_URL = new URL("../src/generated/", import.meta.url); + +const runScript = <A, E extends Error>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, +): Promise<A> => Effect.runPromise(Effect.orDie(Effect.provide(effect, BunServices.layer))); function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } -export function loadSpec(): OpenApiDocument { - const parsed = JSON.parse(readFileSync(sourceSpecPath, "utf8")); +function parseSpec(parsed: unknown, sourceLabel: string): OpenApiDocument { if (!isRecord(parsed) || !isRecord(parsed.paths)) { - throw new Error(`Invalid OpenAPI document at ${sourceSpecPath}`); + throw new Error(`Invalid OpenAPI document at ${sourceLabel}`); } const paths: OpenApiDocument["paths"] = {}; @@ -181,6 +182,10 @@ export function loadSpec(): OpenApiDocument { }; } +export function loadSpec(): OpenApiDocument { + return parseSpec(openApiSnapshot, SOURCE_SPEC_URL.pathname); +} + function camelize(value: string): string { let out = ""; let hadSymbol = false; @@ -854,7 +859,10 @@ function renderSchemaSource( } } - return parts.join("\n") + "\n"; + // Effect's `Schema.Number` accepts non-finite values. OpenAPI numeric fields + // represent wire values, so keep the generated contracts finite by default + // and satisfy the Effect lint rule without hand-editing generated output. + return parts.join("\n").replace(/Schema\.Number\b/gu, "Schema.Finite") + "\n"; } function renderRequestBody(definition: RequestBodyDefinition): string { @@ -1020,20 +1028,36 @@ ${executorCases} `; } -function main() { - const document = loadSpec(); - const operations = extractOperations(document); +const generate = () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const generatedDir = yield* path.fromFileUrl(GENERATED_DIR_URL); + const document = loadSpec(); + const operations = extractOperations(document); - rmSync(generatedDir, { recursive: true, force: true }); - mkdirSync(generatedDir, { recursive: true }); + yield* fs.remove(generatedDir, { recursive: true, force: true }); + yield* fs.makeDirectory(generatedDir, { recursive: true }); - writeFileSync(path.join(generatedDir, "contracts.ts"), renderContracts(document, operations)); - writeFileSync(path.join(generatedDir, "effect-client.ts"), renderEffectClient(operations)); - writeFileSync(path.join(generatedDir, "openapi.json"), `${JSON.stringify(document, null, 2)}\n`); + yield* fs.writeFileString( + path.join(generatedDir, "contracts.ts"), + renderContracts(document, operations), + ); + yield* fs.writeFileString( + path.join(generatedDir, "effect-client.ts"), + renderEffectClient(operations), + ); + const encodedDocument = yield* Schema.encodeEffect( + Schema.fromJsonString(Schema.Unknown, { space: 2 }), + )(document); + yield* fs.writeFileString(path.join(generatedDir, "openapi.json"), `${encodedDocument}\n`); - console.log(`Generated ${operations.length} API operations in ${generatedDir}`); -} + yield* Effect.log(`Generated ${operations.length} API operations in ${generatedDir}`); + }); if (import.meta.main) { - main(); + runScript(generate()).catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); } diff --git a/packages/api/scripts/generated-output-sync.unit.test.ts b/packages/api/scripts/generated-output-sync.unit.test.ts index e1c3dda7f1..38b1c08e2e 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 { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as Schema from "effect/Schema"; import { describe, expect, test } from "vitest"; import { extractOperations, loadSpec, renderContracts, renderEffectClient } from "./generate.ts"; @@ -15,34 +16,56 @@ import { extractOperations, loadSpec, renderContracts, renderEffectClient } from // editing the snapshot and the generated output consistently, which the // hourly upstream sync then catches. -const scriptDir = path.dirname(fileURLToPath(import.meta.url)); -const packageDir = path.join(scriptDir, ".."); -const generatedDir = path.join(packageDir, "src", "generated"); -const oxfmtBin = path.join(packageDir, "node_modules", ".bin", "oxfmt"); +const packageDirUrl = new URL("..", import.meta.url); +const generatedDirUrl = new URL("../src/generated/", import.meta.url); +const oxfmtBinUrl = new URL("../node_modules/.bin/oxfmt", import.meta.url); -function formatWithOxfmt(source: string, fileName: string): string { +const runPlatform = <A, E extends Error>( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >, +): Promise<A> => Effect.runPromise(effect.pipe(Effect.orDie, Effect.provide(BunServices.layer))); + +function formatWithOxfmt(source: string, fileName: string): Promise<string> { // oxfmt runs in file mode (also what the pipeline's fmt:fix runs) rather // than through stdin/stdout: Bun on Linux truncates a child's piped stdout // at ~219 KB, and these renders are 600+ KB. The temp directory lives // inside the package so oxfmt resolves the same configuration, but not // under node_modules, which oxfmt skips by default. - const tempDir = mkdtempSync(path.join(packageDir, ".generated-output-sync-")); - try { - const tempFile = path.join(tempDir, fileName); - writeFileSync(tempFile, source); - execFileSync(oxfmtBin, [tempFile], { cwd: packageDir }); - return readFileSync(tempFile, "utf8"); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } + return runPlatform( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const packageDir = yield* path.fromFileUrl(packageDirUrl); + const oxfmtBin = yield* path.fromFileUrl(oxfmtBinUrl); + const tempDir = yield* fs.makeTempDirectory({ + directory: packageDir, + prefix: ".generated-output-sync-", + }); + const tempFile = path.join(tempDir, fileName); + yield* fs.writeFileString(tempFile, source); + yield* spawner.string(ChildProcess.make(oxfmtBin, [tempFile], { cwd: packageDir })); + const output = yield* fs.readFileString(tempFile); + yield* fs.remove(tempDir, { recursive: true, force: true }); + return output; + }), + ); } -function committedFile(fileName: string): string { - return readFileSync(path.join(generatedDir, fileName), "utf8"); -} +const committedFile = (fileName: string): Promise<string> => + runPlatform( + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const generatedDir = yield* path.fromFileUrl(generatedDirUrl); + return yield* fs.readFileString(path.join(generatedDir, fileName)); + }), + ); -function expectSameSource(rendered: string, fileName: string): void { - const committed = committedFile(fileName); +function expectSameSource(rendered: string, committed: string, fileName: string): void { if (rendered === committed) { return; } @@ -74,9 +97,11 @@ describe("generated output sync", () => { "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", + return formatWithOxfmt(renderContracts(document, operations), "contracts.ts").then( + (rendered) => + committedFile("contracts.ts").then((committed) => + expectSameSource(rendered, committed, "contracts.ts"), + ), ); }, ); @@ -85,9 +110,10 @@ describe("generated output sync", () => { "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", + return formatWithOxfmt(renderEffectClient(operations), "effect-client.ts").then((rendered) => + committedFile("effect-client.ts").then((committed) => + expectSameSource(rendered, committed, "effect-client.ts"), + ), ); }, ); @@ -96,9 +122,13 @@ describe("generated output sync", () => { "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", + const encoded = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown, { space: 2 }))( + document, + ); + return formatWithOxfmt(`${encoded}\n`, "openapi.json").then((rendered) => + committedFile("openapi.json").then((committed) => + expectSameSource(rendered, committed, "openapi.json"), + ), ); }, ); diff --git a/packages/api/src/bun.ts b/packages/api/src/bun.ts index 15b5f1914c..f85be9a321 100644 --- a/packages/api/src/bun.ts +++ b/packages/api/src/bun.ts @@ -6,13 +6,14 @@ import { makeApiClient, type ApiClient } from "./effect.ts"; import { type SupabaseApiClientOptions, type SupabaseApiConfig } from "./internal/client.ts"; import { makePromiseClient, type PromiseClient } from "./internal/promise-client.ts"; -export async function createApiClient( +export function createApiClient( config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions, ): Promise<PromiseSupabaseApiClient> { const runtime = ManagedRuntime.make(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)); - const effectClient = await runtime.runPromise(makeApiClient(config, options)); - return makePromiseClient(runtime, effectClient); + return runtime + .runPromise(makeApiClient(config, options)) + .then((effectClient) => makePromiseClient(runtime, effectClient)); } export type PromiseSupabaseApiClient = PromiseClient<ApiClient>; diff --git a/packages/api/src/config/api-config.layer.unit.test.ts b/packages/api/src/config/api-config.layer.unit.test.ts index 0951f499c5..23383e7446 100644 --- a/packages/api/src/config/api-config.layer.unit.test.ts +++ b/packages/api/src/config/api-config.layer.unit.test.ts @@ -1,27 +1,29 @@ import { describe, expect, test } from "vitest"; -import { ConfigProvider, Effect, Option } from "effect"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; import { apiConfigLayer, DEFAULT_SUPABASE_API_URL } from "./api-config.layer.ts"; import { ApiConfig } from "./api-config.service.ts"; describe("apiConfigLayer", () => { - test("defaults the API URL and reads the access token from config", async () => { - const config = await Effect.runPromise( + test("defaults the API URL and reads the access token from config", () => + Effect.runPromise( Effect.gen(function* () { - return yield* ApiConfig; - }).pipe( - Effect.provide(apiConfigLayer), - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - SUPABASE_ACCESS_TOKEN: "env-token", - }), + const config = yield* ApiConfig.pipe( + Effect.provide( + apiConfigLayer.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + SUPABASE_ACCESS_TOKEN: "env-token", + }), + ), + ), + ), ), - ), - ), - ); + ); - expect(config.baseUrl).toBe(DEFAULT_SUPABASE_API_URL); - expect(Option.isSome(config.accessToken)).toBe(true); - }); + expect(config.baseUrl).toBe(DEFAULT_SUPABASE_API_URL); + expect(Option.isSome(config.accessToken)).toBe(true); + }), + )); }); diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index dc54c794a5..1ea392e40f 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -93,10 +93,10 @@ describe("SSO provider contracts", () => { test("decodes SSO provider responses without nested SAML and domain IDs", () => { expect(() => - Schema.decodeUnknownSync(V1ListAllSsoProviderOutput)({ items: [SPARSE_PROVIDER] }), + Schema.decodeSync(V1ListAllSsoProviderOutput)({ items: [SPARSE_PROVIDER] }), ).not.toThrow(); for (const schema of SINGLE_PROVIDER_SCHEMAS) { - expect(() => Schema.decodeUnknownSync(schema)(SPARSE_PROVIDER)).not.toThrow(); + expect(() => Schema.decodeSync(schema)(SPARSE_PROVIDER)).not.toThrow(); } }); @@ -109,7 +109,7 @@ describe("SSO provider contracts", () => { // Go's structs have no such fields, so `encoding/json` never echoes them. for (const schema of SINGLE_PROVIDER_SCHEMAS) { - const decoded = Schema.decodeUnknownSync(schema)(withLegacyIds); + const decoded = Schema.decodeSync(schema)(withLegacyIds); expect(decoded.saml).not.toHaveProperty("id"); expect(decoded.domains?.[0]).not.toHaveProperty("id"); } @@ -117,7 +117,7 @@ describe("SSO provider contracts", () => { test("accepts object-valued SSO attribute mapping defaults", () => { expect(() => - Schema.decodeUnknownSync(V1CreateASsoProviderInput)({ + Schema.decodeSync(V1CreateASsoProviderInput)({ ref: "abcdefghijklmnopqrst", type: "saml", attribute_mapping: { @@ -133,7 +133,7 @@ describe("SSO provider contracts", () => { describe("database OpenAPI response contract", () => { test("accepts a normal non-empty OpenAPI document", () => { expect(() => - Schema.decodeUnknownSync(V1GetDatabaseOpenapiOutput)({ + Schema.decodeSync(V1GetDatabaseOpenapiOutput)({ openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, paths: { @@ -149,230 +149,45 @@ describe("database OpenAPI response contract", () => { }); describe("makeApiClient", () => { - test("allows raw operations to override generated request headers", async () => { - let accept: string | undefined; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - accept = request.headers.accept; - return Effect.succeed(jsonResponse(request, 200, {})); - }), - ), - ), - ); - - await Effect.runPromise( - client.executeRaw( - operationDefinitions.v1GetAFunctionBody, - { - ref: "abcdefghijklmnopqrst", - function_slug: "hello-world", - }, - { Accept: "multipart/form-data" }, - ), - ); - - expect(accept).toBe("multipart/form-data"); - }); - - test("uses the default API URL when baseUrl is omitted", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; - - const client = await Effect.runPromise( - makeApiClient({ accessToken: "test-token" }).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", - }), - ); - - expect(seenRequests).toEqual([ - { - method: "GET", - url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", - }, - ]); - }); - - test("reads the access token from the environment when omitted", async () => { - const seenRequests: Array<{ authorization: string | undefined }> = []; - - const client = await Effect.runPromise( - makeApiClient().pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - authorization: request.headers.authorization, - }); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - SUPABASE_ACCESS_TOKEN: "env-token", + test("allows raw operations to override generated request headers", () => + Effect.runPromise( + Effect.gen(function* () { + let accept: string | undefined; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + accept = request.headers.accept; + return Effect.succeed(jsonResponse(request, 200, {})); }), ), - ), - ), - ); + ); - await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", - }), - ); - - expect(seenRequests).toEqual([{ authorization: "Bearer env-token" }]); - }); + yield* client.executeRaw( + operationDefinitions.v1GetAFunctionBody, + { + ref: "abcdefghijklmnopqrst", + function_slug: "hello-world", + }, + { Accept: "multipart/form-data" }, + ); - test("passes configured default headers through the facade client", async () => { - const seenRequests: Array<{ - command: string | undefined; - commandRunId: string | undefined; - authorization: string | undefined; - }> = []; - - const client = await Effect.runPromise( - makeApiClient({ - ...config, - headers: { - "X-Supabase-Command": "projects get", - "X-Supabase-Command-Run-ID": "run-456", - }, - }).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - command: request.headers["x-supabase-command"], - commandRunId: request.headers["x-supabase-command-run-id"], - authorization: request.headers.authorization, - }); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", + expect(accept).toBe("multipart/form-data"); }), - ); - - expect(seenRequests).toEqual([ - { - command: "projects get", - commandRunId: "run-456", - authorization: "Bearer test-token", - }, - ]); - }); - - test("fails early when no access token is configured", async () => { - const exit = await Effect.runPromise( - makeApiClient().pipe( - Effect.exit, - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - ok: true, - }), - ), - ), - ), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), - ), - ); - - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(String(exit.cause)).toContain("Missing access token"); - } - }); - - test("returns only versioned methods under the v1 namespace", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); - - if ( - request.method === "POST" && - request.url === "https://api.supabase.com/v1/projects" - ) { + )); + + test("uses the default API URL when baseUrl is omitted", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = yield* makeApiClient({ accessToken: "test-token" }).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); return Effect.succeed( jsonResponse(request, 200, { id: "project-id", @@ -383,17 +198,45 @@ describe("makeApiClient", () => { region: "us-east-1", created_at: "2026-03-13T12:00:00.000Z", status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, }), ); - } + }), + ), + ); - if ( - request.method === "GET" && - request.url === "https://api.supabase.com/v1/projects" - ) { - return Effect.succeed( - jsonResponse(request, 200, [ - { + yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); + + expect(seenRequests).toEqual([ + { + method: "GET", + url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", + }, + ]); + }), + )); + + test("reads the access token from the environment when omitted", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ authorization: string | undefined }> = []; + + const client = yield* makeApiClient().pipe( + Effect.provide( + Layer.mergeAll( + httpClientLayer((request) => { + seenRequests.push({ + authorization: request.headers.authorization, + }); + return Effect.succeed( + jsonResponse(request, 200, { id: "project-id", ref: "abcdefghijklmnopqrst", organization_id: "org-id", @@ -408,185 +251,336 @@ describe("makeApiClient", () => { postgres_engine: "17", release_channel: "ga", }, - }, - ]), - ); - } - - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, + }), + ); }), - ); - }), - ), - ), - ); - - expect("createAProject" in client).toBe(false); - expect("getProject" in client).toBe(false); - expect("listAllProjects" in client).toBe(false); - expect(typeof client.v1.createAProject).toBe("function"); - expect(typeof client.v1.getProject).toBe("function"); - expect(typeof client.v1.listAllProjects).toBe("function"); - - const created = await Effect.runPromise( - client.v1.createAProject({ - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ); - const project = await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + SUPABASE_ACCESS_TOKEN: "env-token", + }), + ), + ), + ), + ); + + yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); + + expect(seenRequests).toEqual([{ authorization: "Bearer env-token" }]); }), - ); - const projects = await Effect.runPromise(client.v1.listAllProjects()); + )); + + test("passes configured default headers through the facade client", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ + command: string | undefined; + commandRunId: string | undefined; + authorization: string | undefined; + }> = []; + + const client = yield* makeApiClient({ + ...config, + headers: { + "X-Supabase-Command": "projects get", + "X-Supabase-Command-Run-ID": "run-456", + }, + }).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + command: request.headers["x-supabase-command"], + commandRunId: request.headers["x-supabase-command-run-id"], + authorization: request.headers.authorization, + }); + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, + }), + ); + }), + ), + ); - expect(created.ref).toBe("abcdefghijklmnopqrst"); - expect(project.database.host).toBe("db.supabase.internal"); - expect(projects).toHaveLength(1); - expect(seenRequests).toEqual([ - { - method: "POST", - url: "https://api.supabase.com/v1/projects", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects", - }, - ]); - }); + yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); - test("addresses same-named v1 and v2 operations independently by namespace", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; + expect(seenRequests).toEqual([ + { + command: "projects get", + commandRunId: "run-456", + authorization: "Bearer test-token", + }, + ]); + }), + )); + + test("fails early when no access token is configured", () => + Effect.runPromise( + Effect.gen(function* () { + const exit = yield* makeApiClient().pipe( + Effect.exit, + Effect.provide( + Layer.mergeAll( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + ok: true, + }), + ), + ), + ConfigProvider.layer(ConfigProvider.fromUnknown({})), + ), + ), + ); - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain("Missing access token"); + } + }), + )); + + test("returns only versioned methods under the v1 namespace", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); + + if ( + request.method === "POST" && + request.url === "https://api.supabase.com/v1/projects" + ) { + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }), + ); + } + + if ( + request.method === "GET" && + request.url === "https://api.supabase.com/v1/projects" + ) { + return Effect.succeed( + jsonResponse(request, 200, [ + { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, + }, + ]), + ); + } - if (request.url === "https://api.supabase.com/v1/organizations/my-org/members") { return Effect.succeed( - jsonResponse(request, 200, [ - { - user_id: "user-id", - user_name: "user-name", - role_name: "Owner", - mfa_enabled: false, - avatar_url: null, + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", }, - ]), + }), ); - } + }), + ), + ); + + expect("createAProject" in client).toBe(false); + expect("getProject" in client).toBe(false); + expect("listAllProjects" in client).toBe(false); + expect(typeof client.v1.createAProject).toBe("function"); + expect(typeof client.v1.getProject).toBe("function"); + expect(typeof client.v1.listAllProjects).toBe("function"); + + const created = yield* client.v1.createAProject({ + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }); + const project = yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); + const projects = yield* client.v1.listAllProjects(); + + expect(created.ref).toBe("abcdefghijklmnopqrst"); + expect(project.database.host).toBe("db.supabase.internal"); + expect(projects).toHaveLength(1); + expect(seenRequests).toEqual([ + { + method: "POST", + url: "https://api.supabase.com/v1/projects", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects", + }, + ]); + }), + )); + + test("addresses same-named v1 and v2 operations independently by namespace", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); + + if (request.url === "https://api.supabase.com/v1/organizations/my-org/members") { + return Effect.succeed( + jsonResponse(request, 200, [ + { + user_id: "user-id", + user_name: "user-name", + role_name: "Owner", + mfa_enabled: false, + avatar_url: null, + }, + ]), + ); + } - return Effect.succeed( - jsonResponse(request, 200, { - data: [], - links: { prev: null, next: null }, - }), - ); - }), - ), - ), - ); - - expect(typeof client.v1.listOrganizationMembers).toBe("function"); - expect(typeof client.v2.listOrganizationMembers).toBe("function"); - - const v1Members = await Effect.runPromise( - client.v1.listOrganizationMembers({ slug: "my-org" }), - ); - const v2Members = await Effect.runPromise( - client.v2.listOrganizationMembers({ slug: "my-org" }), - ); - - expect(v1Members).toEqual([ - { - user_id: "user-id", - user_name: "user-name", - role_name: "Owner", - mfa_enabled: false, - avatar_url: null, - }, - ]); - expect(v2Members.data).toEqual([]); - expect(seenRequests).toEqual([ - { - method: "GET", - url: "https://api.supabase.com/v1/organizations/my-org/members", - }, - { - method: "GET", - url: "https://api.supabase.com/v2/organizations/my-org/members", - }, - ]); - }); + return Effect.succeed( + jsonResponse(request, 200, { + data: [], + links: { prev: null, next: null }, + }), + ); + }), + ), + ); - test("serializes generated binary methods through the effect facade", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed( - jsonResponse(request, 201, { - id: "function-id", - slug: "demo", - name: "Demo Function", - status: "ACTIVE", - version: 1, - created_at: 1_710_000_000, - updated_at: 1_710_000_001, - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - ezbr_sha256: "abc123", - }), - ); - }), - ), - ), - ); - - const body = new Blob(["console.log('blob body');"]); - const result = await Effect.runPromise( - client.v1.createAFunction({ - ref: "abcdefghijklmnopqrst", - slug: "demo", - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - body, + expect(typeof client.v1.listOrganizationMembers).toBe("function"); + expect(typeof client.v2.listOrganizationMembers).toBe("function"); + + const v1Members = yield* client.v1.listOrganizationMembers({ slug: "my-org" }); + const v2Members = yield* client.v2.listOrganizationMembers({ slug: "my-org" }); + + expect(v1Members).toEqual([ + { + user_id: "user-id", + user_name: "user-name", + role_name: "Owner", + mfa_enabled: false, + avatar_url: null, + }, + ]); + expect(v2Members.data).toEqual([]); + expect(seenRequests).toEqual([ + { + method: "GET", + url: "https://api.supabase.com/v1/organizations/my-org/members", + }, + { + method: "GET", + url: "https://api.supabase.com/v2/organizations/my-org/members", + }, + ]); }), - ); + )); - expect(result.slug).toBe("demo"); - expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); - expect(new URL(seenRequest!.url).pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions"); - expect(requestBodyText(seenRequest!)).toBe("console.log('blob body');"); - }); + test("serializes generated binary methods through the effect facade", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 201, { + id: "function-id", + slug: "demo", + name: "Demo Function", + status: "ACTIVE", + version: 1, + created_at: 1_710_000_000, + updated_at: 1_710_000_001, + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + ezbr_sha256: "abc123", + }), + ); + }), + ), + ); + + const body = new Blob(["console.log('blob body');"]); + const result = yield* client.v1.createAFunction({ + ref: "abcdefghijklmnopqrst", + slug: "demo", + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + body, + }); + + expect(result.slug).toBe("demo"); + expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); + expect(new URL(seenRequest!.url).pathname).toBe( + "/v1/projects/abcdefghijklmnopqrst/functions", + ); + expect(requestBodyText(seenRequest!)).toBe("console.log('blob body');"); + }), + )); }); diff --git a/packages/api/src/entrypoints.unit.test.ts b/packages/api/src/entrypoints.unit.test.ts index 1590fdcbf0..dd2388c0e6 100644 --- a/packages/api/src/entrypoints.unit.test.ts +++ b/packages/api/src/entrypoints.unit.test.ts @@ -1,12 +1,27 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import * as Schema from "effect/Schema"; import { describe, expect, test } from "vitest"; import * as effectModule from "./effect.ts"; import { createApiClient as createNodeApiClient } from "./node.ts"; +const readSource = (relativePath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const root = yield* path.fromFileUrl(new URL("./", import.meta.url)); + return yield* fs.readFileString(path.join(root, relativePath)); + }).pipe(Effect.provide(BunServices.layer)); + +const sourceExists = (relativePath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const root = yield* path.fromFileUrl(new URL("./", import.meta.url)); + return yield* fs.exists(path.join(root, relativePath)); + }).pipe(Effect.provide(BunServices.layer)); + describe("@supabase/api entrypoints", () => { test("exports the generated contracts without embedding the OpenAPI document", () => { expect(effectModule.operationDefinitions.v1CreateAProject.method).toBe("POST"); @@ -18,44 +33,61 @@ describe("@supabase/api entrypoints", () => { expect("v1ListAllProjects" in effectModule).toBe(false); }); - test("exports runtime-specific client builders", () => { - const srcDir = dirname(fileURLToPath(import.meta.url)); - const bunSource = readFileSync(join(srcDir, "bun.ts"), "utf8"); + test("exports runtime-specific client builders", () => + Effect.runPromise( + Effect.gen(function* () { + const bunSource = yield* readSource("bun.ts"); + expect(bunSource).toContain("export function createApiClient"); + expect(typeof createNodeApiClient).toBe("function"); + expect(typeof effectModule.makeApiClient).toBe("function"); + expect(effectModule.ApiConfig).toBeDefined(); + expect(effectModule.apiConfigLayer).toBeDefined(); + expect(effectModule.DEFAULT_SUPABASE_API_URL).toBe("https://api.supabase.com"); + expect(bunSource).not.toContain("clientLayer"); + }), + )); - expect(bunSource).toContain("export async function createApiClient"); - expect(typeof createNodeApiClient).toBe("function"); - expect(typeof effectModule.makeApiClient).toBe("function"); - expect(effectModule.ApiConfig).toBeDefined(); - expect(effectModule.apiConfigLayer).toBeDefined(); - expect(effectModule.DEFAULT_SUPABASE_API_URL).toBe("https://api.supabase.com"); - expect(bunSource).not.toContain("clientLayer"); - }); + test("does not generate separate promise or standalone operation artifacts", () => + Effect.runPromise( + Effect.gen(function* () { + expect(yield* sourceExists("generated/promise-client.ts")).toBe(false); + expect(yield* sourceExists("generated/effect-operations.ts")).toBe(false); + }), + )); - test("does not generate separate promise or standalone operation artifacts", () => { - const srcDir = dirname(fileURLToPath(import.meta.url)); - expect(existsSync(join(srcDir, "generated/promise-client.ts"))).toBe(false); - expect(existsSync(join(srcDir, "generated/effect-operations.ts"))).toBe(false); - }); - - test("ships the OpenAPI spec as a json subpath artifact", () => { - const srcDir = dirname(fileURLToPath(import.meta.url)); - const packageJson = JSON.parse(readFileSync(join(srcDir, "../package.json"), "utf8")) as { - readonly exports: Record<string, string | Record<string, string>>; - }; - const openApiDocument = JSON.parse( - readFileSync(join(srcDir, "generated/openapi.json"), "utf8"), - ) as { readonly openapi: string }; + test("ships the OpenAPI spec as a json subpath artifact", () => + Effect.runPromise( + Effect.gen(function* () { + const packageJsonUnknown = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Unknown), + )(yield* readSource("../package.json")); + const openApiDocumentUnknown = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Unknown), + )(yield* readSource("generated/openapi.json")); + if ( + typeof packageJsonUnknown !== "object" || + packageJsonUnknown === null || + typeof openApiDocumentUnknown !== "object" || + openApiDocumentUnknown === null + ) { + throw new Error("Expected package and OpenAPI JSON objects"); + } + const packageJson = packageJsonUnknown as { + readonly exports: Record<string, string | Record<string, string>>; + }; + const openApiDocument = openApiDocumentUnknown as { readonly openapi: string }; - expect(packageJson.exports["."]).toEqual({ - bun: "./src/bun.ts", - default: "./src/node.ts", - }); - expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); - expect(packageJson.exports["./openapi.json"]).toBe("./src/generated/openapi.json"); - expect(packageJson.exports["./bun"]).toBeUndefined(); - expect(packageJson.exports["./node"]).toBeUndefined(); - expect(openApiDocument.openapi).toBe("3.0.0"); - }); + expect(packageJson.exports["."]).toEqual({ + bun: "./src/bun.ts", + default: "./src/node.ts", + }); + expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); + expect(packageJson.exports["./openapi.json"]).toBe("./src/generated/openapi.json"); + expect(packageJson.exports["./bun"]).toBeUndefined(); + expect(packageJson.exports["./node"]).toBeUndefined(); + expect(openApiDocument.openapi).toBe("3.0.0"); + }), + )); test("exports a stable raw OpenAPI operation id map", () => { expect(Object.keys(effectModule.openApiOperationIdMap)).toHaveLength( diff --git a/packages/api/src/generated-contract-sync.unit.test.ts b/packages/api/src/generated-contract-sync.unit.test.ts index 1dc1641a04..0442d619bd 100644 --- a/packages/api/src/generated-contract-sync.unit.test.ts +++ b/packages/api/src/generated-contract-sync.unit.test.ts @@ -1,7 +1,6 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; +import * as Schema from "effect/Schema"; import { describe, expect, test } from "vitest"; import { openApiOperationIdMap, operationDefinitions } from "./generated/contracts.ts"; @@ -25,9 +24,21 @@ interface SnapshotOperation { readonly operationId: string; } -const openApiJsonPath = join(dirname(fileURLToPath(import.meta.url)), "generated/openapi.json"); -const rawOpenApiJson = readFileSync(openApiJsonPath, "utf8"); -const openApiDocument = JSON.parse(rawOpenApiJson) as OpenApiDocumentShape; +const openApiSnapshot = await Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const openApiJsonPath = yield* path.fromFileUrl( + new URL("./generated/openapi.json", import.meta.url), + ); + return { + raw: yield* fs.readFileString(openApiJsonPath), + }; +}).pipe(Effect.provide(BunServices.layer), Effect.runPromise); + +const rawOpenApiJson = openApiSnapshot.raw; +const parsedOpenApiDocument = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown))( + rawOpenApiJson, +); function extractSnapshotOperations( document: OpenApiDocumentShape, @@ -69,6 +80,16 @@ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> { return typeof value === "object" && value !== null; } +function isOpenApiDocument(value: unknown): value is OpenApiDocumentShape { + return isRecord(value) && isRecord(value.paths); +} + +if (!isOpenApiDocument(parsedOpenApiDocument)) { + throw new Error("Expected an OpenAPI document with a paths object"); +} + +const openApiDocument = parsedOpenApiDocument; + function stringProperty(value: unknown, key: string): string { if (!isRecord(value)) { throw new Error(`Expected an object while reading property "${key}"`); @@ -176,7 +197,14 @@ describe("generated client drift against the committed openapi.json snapshot", ( // instead checks that the committed bytes parse deterministically and keep // the single trailing newline `scripts/generate.ts` writes. test("parses the committed snapshot deterministically and keeps a single trailing newline", () => { - const reparsed = JSON.parse(readFileSync(openApiJsonPath, "utf8")) as OpenApiDocumentShape; + const reparsedUnknown = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown))( + rawOpenApiJson, + ); + expect(isOpenApiDocument(reparsedUnknown)).toBe(true); + if (!isOpenApiDocument(reparsedUnknown)) { + return; + } + const reparsed = reparsedUnknown; expect(reparsed).toEqual(openApiDocument); expect(rawOpenApiJson.endsWith("\n")).toBe(true); expect(rawOpenApiJson.endsWith("\n\n")).toBe(false); diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 5ab5af98c4..ac9e4a60ad 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -7,7 +7,7 @@ export const SupavisorConfigResponse = Schema.Struct({ is_using_scram_auth: Schema.Boolean, db_user: Schema.String, db_host: Schema.String, - db_port: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + db_port: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -22,7 +22,7 @@ export const SupavisorConfigResponse = Schema.Struct({ connection_string: Schema.String, connectionString: Schema.String.annotate({ description: "Use connection_string instead" }), default_pool_size: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -36,7 +36,7 @@ export const SupavisorConfigResponse = Schema.Struct({ Schema.Null, ]), max_client_conn: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -107,7 +107,7 @@ export const V1ServiceHealthResponse = Schema.Struct({ healthy: Schema.Boolean.annotate({ description: "Deprecated. Use `status` instead." }), db_connected: Schema.Boolean, replication_connected: Schema.Boolean, - connected_cluster: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + connected_cluster: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -141,7 +141,7 @@ export const BranchResponse = Schema.Struct({ is_default: Schema.Boolean, git_branch: Schema.optionalKey(Schema.String), pr_number: Schema.optionalKey( - Schema.Number.annotate({ format: "int32" }) + Schema.Finite.annotate({ format: "int32" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -155,7 +155,7 @@ export const BranchResponse = Schema.Struct({ ), ), latest_check_run_id: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "This field is deprecated and will not be populated.", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -209,7 +209,7 @@ export const FunctionResponse = Schema.Struct({ slug: Schema.String, name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -220,7 +220,7 @@ export const FunctionResponse = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - created_at: Schema.Number.annotate({ format: "int64" }) + created_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -232,7 +232,7 @@ export const FunctionResponse = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - updated_at: Schema.Number.annotate({ format: "int64" }) + updated_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -353,7 +353,7 @@ export const UpdateCustomHostnameResponseJsonValue = Schema.Union([ Schema.Union([ Schema.Union([ Schema.String, - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Boolean, ]), Schema.Null, @@ -386,7 +386,7 @@ export const ListProjectAddonsResponseJsonValue = Schema.Union([ Schema.Union([ Schema.Union([ Schema.String, - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Boolean, ]), Schema.Null, @@ -458,7 +458,7 @@ export const V1AcceptInviteExternalJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -679,7 +679,7 @@ export const V1AuthorizeJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -809,7 +809,7 @@ export const V1BulkUpdateFunctionsInput = Schema.Struct({ ), name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -821,7 +821,7 @@ export const V1BulkUpdateFunctionsInput = Schema.Struct({ }), ), created_at: Schema.optionalKey( - Schema.Number.annotate({ format: "int64" }) + Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -849,7 +849,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ slug: Schema.String, name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -860,7 +860,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - created_at: Schema.Number.annotate({ format: "int64" }) + created_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -872,7 +872,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - updated_at: Schema.Number.annotate({ format: "int64" }) + updated_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -1016,7 +1016,7 @@ export const V1CreateABranchOutput = Schema.Struct({ is_default: Schema.Boolean, git_branch: Schema.optionalKey(Schema.String), pr_number: Schema.optionalKey( - Schema.Number.annotate({ format: "int32" }) + Schema.Finite.annotate({ format: "int32" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -1030,7 +1030,7 @@ export const V1CreateABranchOutput = Schema.Struct({ ), ), latest_check_run_id: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "This field is deprecated and will not be populated.", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -1101,7 +1101,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ slug: Schema.String, name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -1112,7 +1112,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - created_at: Schema.Number.annotate({ format: "int64" }) + created_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -1124,7 +1124,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - updated_at: Schema.Number.annotate({ format: "int64" }) + updated_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -1460,7 +1460,7 @@ export const V1CreateLoginRoleOutput = Schema.Struct({ password: Schema.String.check( Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), - ttl_seconds: Schema.Number.annotate({ format: "int64" }) + ttl_seconds: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1" }), @@ -1858,7 +1858,7 @@ export const V1DeleteAProjectInput = Schema.Struct({ ), }); export const V1DeleteAProjectOutput = Schema.Struct({ - id: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + id: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -2157,7 +2157,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ slug: Schema.String, name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -2169,7 +2169,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ }), ), created_at: Schema.optionalKey( - Schema.Number.annotate({ format: "int64" }) + Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2183,7 +2183,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ ), ), updated_at: Schema.optionalKey( - Schema.Number.annotate({ format: "int64" }) + Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2309,7 +2309,7 @@ export const V1ExchangeOauthTokenOutput = Schema.Struct({ "The `urn:ietf:params:oauth:grant-type:jwt-bearer` grant type issues access tokens only, no refresh token is returned and the token cannot be revoked via `/v1/oauth/revoke`.", }), ), - expires_in: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + expires_in: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -2364,7 +2364,7 @@ export const V1GetABranchOutput = Schema.Struct({ is_default: Schema.Boolean, git_branch: Schema.optionalKey(Schema.String), pr_number: Schema.optionalKey( - Schema.Number.annotate({ format: "int32" }) + Schema.Finite.annotate({ format: "int32" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2378,7 +2378,7 @@ export const V1GetABranchOutput = Schema.Struct({ ), ), latest_check_run_id: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "This field is deprecated and will not be populated.", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -2464,7 +2464,7 @@ export const V1GetABranchConfigOutput = Schema.Struct({ "RESIZING", ]), db_host: Schema.String, - db_port: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + db_port: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -2496,7 +2496,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ slug: Schema.String, name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -2507,7 +2507,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - created_at: Schema.Number.annotate({ format: "int64" }) + created_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2519,7 +2519,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - updated_at: Schema.Number.annotate({ format: "int64" }) + updated_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2601,15 +2601,15 @@ export const V1GetASnippetOutput = Schema.Struct({ name: Schema.String, description: Schema.Union([Schema.String, Schema.Null]), project: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }), owner: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), username: Schema.String, }), updated_by: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), username: Schema.String, }), favorite: Schema.Boolean, @@ -2724,7 +2724,7 @@ export const V1GetActionRunOutput = Schema.Struct({ ), workdir: Schema.Union([Schema.String, Schema.Null]), check_run_id: Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), created_at: Schema.String, @@ -2750,7 +2750,7 @@ export const V1GetAllProjectsForOrganizationInput = Schema.Struct({ }), ), offset: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -2763,7 +2763,7 @@ export const V1GetAllProjectsForOrganizationInput = Schema.Struct({ ), ), limit: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -2850,11 +2850,11 @@ export const V1GetAllProjectsForOrganizationOutput = Schema.Struct({ identifier: Schema.String, type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), disk_volume_size_gb: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), disk_throughput_mbps: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), disk_last_modified_at: Schema.optionalKey(Schema.String), }), @@ -2862,13 +2862,13 @@ export const V1GetAllProjectsForOrganizationOutput = Schema.Struct({ }), ), pagination: Schema.Struct({ - count: Schema.Number.annotate({ + count: Schema.Finite.annotate({ description: "Total number of projects. Use this to calculate the total number of pages.", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), - limit: Schema.Number.annotate({ description: "Maximum number of projects per page" }).check( + limit: Schema.Finite.annotate({ description: "Maximum number of projects per page" }).check( Schema.isFinite().annotate({ expected: "a finite number" }), ), - offset: Schema.Number.annotate({ + offset: Schema.Finite.annotate({ description: "Number of projects skipped in this response", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), }), @@ -2908,7 +2908,7 @@ export const V1GetAuthServiceConfigInput = Schema.Struct({ }); export const V1GetAuthServiceConfigOutput = Schema.Struct({ api_max_request_duration: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -2922,7 +2922,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), db_max_pool_size: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3058,7 +3058,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ hook_after_user_created_uri: Schema.Union([Schema.String, Schema.Null]), hook_after_user_created_secrets: Schema.Union([Schema.String, Schema.Null]), jwt_exp: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3073,7 +3073,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ]), mailer_allow_unverified_email_sign_ins: Schema.Union([Schema.Boolean, Schema.Null]), mailer_autoconfirm: Schema.Union([Schema.Boolean, Schema.Null]), - mailer_otp_exp: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + mailer_otp_exp: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3085,7 +3085,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ }), ), mailer_otp_length: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3145,7 +3145,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ mailer_notifications_identity_linked_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mailer_notifications_identity_unlinked_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_max_enrolled_factors: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3168,7 +3168,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ webauthn_rp_display_name: Schema.Union([Schema.String, Schema.Null]), webauthn_rp_id: Schema.Union([Schema.String, Schema.Null]), webauthn_rp_origins: Schema.Union([Schema.String, Schema.Null]), - mfa_phone_otp_length: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + mfa_phone_otp_length: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3181,7 +3181,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ), mfa_phone_template: Schema.Union([Schema.String, Schema.Null]), mfa_phone_max_frequency: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3199,7 +3199,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ nimbus_oauth_client_secret: Schema.Union([Schema.String, Schema.Null]), password_hibp_enabled: Schema.Union([Schema.Boolean, Schema.Null]), password_min_length: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3222,7 +3222,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_anonymous_users: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3236,7 +3236,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_email_sent: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3250,7 +3250,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_sms_sent: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3264,7 +3264,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_token_refresh: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3278,7 +3278,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_verify: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3292,7 +3292,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_otp: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3306,7 +3306,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_web3: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3333,7 +3333,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ security_captcha_secret: Schema.Union([Schema.String, Schema.Null]), security_manual_linking_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_refresh_token_reuse_interval: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3348,19 +3348,19 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ]), security_update_password_require_reauthentication: Schema.Union([Schema.Boolean, Schema.Null]), sessions_inactivity_timeout: Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), sessions_single_per_user: Schema.Union([Schema.Boolean, Schema.Null]), sessions_tags: Schema.Union([Schema.String, Schema.Null]), sessions_timebox: Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), site_url: Schema.Union([Schema.String, Schema.Null]), sms_autoconfirm: Schema.Union([Schema.Boolean, Schema.Null]), sms_max_frequency: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3376,7 +3376,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ sms_messagebird_access_key: Schema.Union([Schema.String, Schema.Null]), sms_messagebird_originator: Schema.Union([Schema.String, Schema.Null]), sms_otp_exp: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3389,7 +3389,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ), Schema.Null, ]), - sms_otp_length: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + sms_otp_length: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3441,7 +3441,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ]), smtp_host: Schema.Union([Schema.String, Schema.Null]), smtp_max_frequency: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -3463,7 +3463,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ oauth_server_allow_dynamic_registration: Schema.Boolean, oauth_server_authorization_path: Schema.Union([Schema.String, Schema.Null]), custom_oauth_enabled: Schema.Boolean, - custom_oauth_max_providers: Schema.Number.check( + custom_oauth_max_providers: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -3619,14 +3619,14 @@ export const V1GetDatabaseDiskInput = Schema.Struct({ export const V1GetDatabaseDiskOutput = Schema.Struct({ attributes: Schema.Union([ Schema.Struct({ - iops: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + iops: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", }), ) .check(Schema.isGreaterThan(0).annotate({ expected: "a value greater than 0" })), - size_gb: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + size_gb: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -3634,7 +3634,7 @@ export const V1GetDatabaseDiskOutput = Schema.Struct({ ) .check(Schema.isGreaterThan(0).annotate({ expected: "a value greater than 0" })), throughput_mibps: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -3645,14 +3645,14 @@ export const V1GetDatabaseDiskOutput = Schema.Struct({ type: Schema.Literal("gp3"), }), Schema.Struct({ - iops: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + iops: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", }), ) .check(Schema.isGreaterThan(0).annotate({ expected: "a value greater than 0" })), - size_gb: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + size_gb: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -3720,11 +3720,11 @@ export const V1GetDiskUtilizationInput = Schema.Struct({ export const V1GetDiskUtilizationOutput = Schema.Struct({ timestamp: Schema.String, metrics: Schema.Struct({ - fs_size_bytes: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - fs_avail_bytes: Schema.Number.check( + fs_size_bytes: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), + fs_avail_bytes: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), - fs_used_bytes: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + fs_used_bytes: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), }); export const V1GetHostnameConfigInput = Schema.Struct({ @@ -3804,7 +3804,7 @@ export const V1GetJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -4020,7 +4020,7 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ Schema.Struct({ enabled: Schema.Boolean }), Schema.Struct({ enabled: Schema.Boolean, - value: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + value: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), unlimited: Schema.Boolean, unit: Schema.String, }), @@ -4047,7 +4047,7 @@ export const V1GetOrganizationProjectClaimOutput = Schema.Struct({ members_exceeding_free_project_limit: Schema.Array( Schema.Struct({ name: Schema.String, - limit: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + limit: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), ), source_subscription_plan: Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), @@ -4136,7 +4136,7 @@ export const V1GetPerformanceAdvisorsOutput = Schema.Struct({ fkey_name: Schema.optionalKey(Schema.String), fkey_columns: Schema.optionalKey( Schema.Array( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), ), }), @@ -4213,7 +4213,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ maintenance_work_mem: Schema.optionalKey(Schema.String), track_activity_query_size: Schema.optionalKey(Schema.String), max_connections: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -4226,7 +4226,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_locks_per_transaction: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(10).annotate({ expected: "a value greater than or equal to 10", @@ -4239,7 +4239,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_logical_replication_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -4252,7 +4252,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_parallel_maintenance_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -4265,7 +4265,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_parallel_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -4278,7 +4278,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_parallel_workers_per_gather: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -4291,7 +4291,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_replication_slots: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4307,7 +4307,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ max_standby_archive_delay: Schema.optionalKey(Schema.String), max_standby_streaming_delay: Schema.optionalKey(Schema.String), max_sync_workers_per_subscription: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -4321,7 +4321,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), max_wal_size: Schema.optionalKey(Schema.String), max_wal_senders: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4334,7 +4334,7 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ ), ), max_worker_processes: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -4404,7 +4404,7 @@ export const V1GetPostgresUpgradeEligibilityOutput = Schema.Struct({ app_version: Schema.String, }), ), - duration_estimate_hours: Schema.Number.check( + duration_estimate_hours: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), legacy_auth_custom_roles: Schema.Array(Schema.String), @@ -4489,7 +4489,7 @@ export const V1GetPostgresUpgradeStatusOutput = Schema.Struct({ Schema.Struct({ initiated_at: Schema.String, latest_status_at: Schema.String, - target_version: Schema.Number.check( + target_version: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), error: Schema.optionalKey( @@ -4520,7 +4520,7 @@ export const V1GetPostgresUpgradeStatusOutput = Schema.Struct({ "10_completed_post_physical_backup", ]), ), - status: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + status: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), Schema.Null, ]), @@ -4538,7 +4538,7 @@ export const V1GetPostgrestServiceConfigInput = Schema.Struct({ }); export const V1GetPostgrestServiceConfigOutput = Schema.Struct({ db_schema: Schema.String, - max_rows: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_rows: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4551,7 +4551,7 @@ export const V1GetPostgrestServiceConfigOutput = Schema.Struct({ ), db_extra_search_path: Schema.String, db_pool: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "If `null`, the value is automatically configured based on compute size.", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -4568,7 +4568,7 @@ export const V1GetPostgrestServiceConfigOutput = Schema.Struct({ Schema.Null, ]), db_pool_acquisition_timeout: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "If `null`, the value is automatically configured to 10.", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -4754,7 +4754,7 @@ export const V1GetProjectDiskAutoscaleConfigInput = Schema.Struct({ }); export const V1GetProjectDiskAutoscaleConfigOutput = Schema.Struct({ growth_percent: Schema.Union([ - Schema.Number.annotate({ description: "Growth percentage for disk autoscaling" }) + Schema.Finite.annotate({ description: "Growth percentage for disk autoscaling" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ @@ -4765,7 +4765,7 @@ export const V1GetProjectDiskAutoscaleConfigOutput = Schema.Struct({ Schema.Null, ]), min_increment_gb: Schema.Union([ - Schema.Number.annotate({ description: "Minimum increment size for disk autoscaling in GB" }) + Schema.Finite.annotate({ description: "Minimum increment size for disk autoscaling in GB" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ @@ -4776,7 +4776,7 @@ export const V1GetProjectDiskAutoscaleConfigOutput = Schema.Struct({ Schema.Null, ]), max_size_gb: Schema.Union([ - Schema.Number.annotate({ description: "Maximum limit the disk size will grow to in GB" }) + Schema.Finite.annotate({ description: "Maximum limit the disk size will grow to in GB" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ @@ -4806,7 +4806,7 @@ export const V1GetProjectFunctionCombinedStatsOutput = Schema.Struct({ Schema.Union([ Schema.String, Schema.Struct({ - code: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + code: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), errors: Schema.Array( Schema.Struct({ domain: Schema.String, @@ -4854,7 +4854,7 @@ export const V1GetProjectLogsOutput = Schema.Struct({ Schema.Union([ Schema.String, Schema.Struct({ - code: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + code: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), errors: Schema.Array( Schema.Struct({ domain: Schema.String, @@ -4890,7 +4890,7 @@ export const V1GetProjectLogsAllOutput = Schema.Struct({ Schema.Union([ Schema.String, Schema.Struct({ - code: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + code: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), errors: Schema.Array( Schema.Struct({ domain: Schema.String, @@ -4919,7 +4919,7 @@ export const V1GetProjectPgbouncerConfigInput = Schema.Struct({ }); export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ default_pool_size: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4933,7 +4933,7 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ ), ignore_startup_parameters: Schema.optionalKey(Schema.String), max_client_conn: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4948,7 +4948,7 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ pool_mode: Schema.optionalKey(Schema.Literals(["transaction", "session", "statement"])), connection_string: Schema.optionalKey(Schema.String), server_idle_timeout: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4961,7 +4961,7 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ ), ), server_lifetime: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4974,7 +4974,7 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ ), ), query_wait_timeout: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -4987,7 +4987,7 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ ), ), reserve_pool_size: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -5134,16 +5134,16 @@ export const V1GetProjectUsageApiCountOutput = Schema.Struct({ Schema.Array( Schema.Struct({ timestamp: Schema.String.annotate({ format: "date-time" }), - total_auth_requests: Schema.Number.check( + total_auth_requests: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), - total_realtime_requests: Schema.Number.check( + total_realtime_requests: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), - total_rest_requests: Schema.Number.check( + total_rest_requests: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), - total_storage_requests: Schema.Number.check( + total_storage_requests: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), }), @@ -5153,7 +5153,7 @@ export const V1GetProjectUsageApiCountOutput = Schema.Struct({ Schema.Union([ Schema.String, Schema.Struct({ - code: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + code: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), errors: Schema.Array( Schema.Struct({ domain: Schema.String, @@ -5184,7 +5184,7 @@ export const V1GetProjectUsageRequestCountOutput = Schema.Struct({ result: Schema.optionalKey( Schema.Array( Schema.Struct({ - count: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + count: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), ), ), @@ -5192,7 +5192,7 @@ export const V1GetProjectUsageRequestCountOutput = Schema.Struct({ Schema.Union([ Schema.String, Schema.Struct({ - code: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + code: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), errors: Schema.Array( Schema.Struct({ domain: Schema.String, @@ -5241,7 +5241,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), connection_pool: Schema.Union([ - Schema.Number.annotate({ description: "Sets connection pool size for Realtime Authorization" }) + Schema.Finite.annotate({ description: "Sets connection pool size for Realtime Authorization" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -5254,7 +5254,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), postgres_changes_pool: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets connection pool size used to create Postgres Changes subscriptions", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -5269,7 +5269,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_concurrent_users: Schema.Union([ - Schema.Number.annotate({ description: "Sets maximum number of concurrent users rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of concurrent users rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -5284,7 +5284,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_events_per_second: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets maximum number of events per second rate per channel limit", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -5301,7 +5301,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_bytes_per_second: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets maximum number of bytes per second rate per channel limit", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -5318,7 +5318,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_channels_per_client: Schema.Union([ - Schema.Number.annotate({ description: "Sets maximum number of channels per client rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of channels per client rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -5333,7 +5333,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_joins_per_second: Schema.Union([ - Schema.Number.annotate({ description: "Sets maximum number of joins per second rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of joins per second rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -5348,7 +5348,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_presence_events_per_second: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets maximum number of presence events per second rate limit", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -5365,7 +5365,7 @@ export const V1GetRealtimeConfigOutput = Schema.Struct({ Schema.Null, ]), max_payload_size_in_kb: Schema.Union([ - Schema.Number.annotate({ description: "Sets maximum number of payload size in KB rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of payload size in KB rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -5473,7 +5473,7 @@ export const V1GetSecurityAdvisorsOutput = Schema.Struct({ fkey_name: Schema.optionalKey(Schema.String), fkey_columns: Schema.optionalKey( Schema.Array( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), ), }), @@ -5511,7 +5511,7 @@ export const V1GetServicesHealthInput = Schema.Struct({ ).annotate({ description: "Array of enums." }), ]), timeout_ms: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -5552,7 +5552,7 @@ export const V1GetStorageConfigInput = Schema.Struct({ ), }); export const V1GetStorageConfigOutput = Schema.Struct({ - fileSizeLimit: Schema.Number.annotate({ format: "int64" }) + fileSizeLimit: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -5570,7 +5570,7 @@ export const V1GetStorageConfigOutput = Schema.Struct({ purgeCache: Schema.Struct({ enabled: Schema.Boolean }), icebergCatalog: Schema.Struct({ enabled: Schema.Boolean, - maxNamespaces: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxNamespaces: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -5581,7 +5581,7 @@ export const V1GetStorageConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - maxTables: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxTables: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -5592,7 +5592,7 @@ export const V1GetStorageConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - maxCatalogs: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxCatalogs: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -5606,7 +5606,7 @@ export const V1GetStorageConfigOutput = Schema.Struct({ }), vectorBuckets: Schema.Struct({ enabled: Schema.Boolean, - maxBuckets: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxBuckets: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -5617,7 +5617,7 @@ export const V1GetStorageConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - maxIndexes: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxIndexes: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -5682,7 +5682,7 @@ export const V1InviteExternalJitAccessInput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -5751,7 +5751,7 @@ export const V1InviteExternalJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -5804,12 +5804,12 @@ export const V1ListActionRunsInput = Schema.Struct({ }), ), offset: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0" }), ), ), limit: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( Schema.isGreaterThanOrEqualTo(10).annotate({ expected: "a value greater than or equal to 10", }), @@ -5849,7 +5849,7 @@ export const V1ListActionRunsOutput = Schema.Array( ), workdir: Schema.Union([Schema.String, Schema.Null]), check_run_id: Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), created_at: Schema.String, @@ -5873,7 +5873,7 @@ export const V1ListAllBackupsOutput = Schema.Struct({ pitr_enabled: Schema.Boolean, backups: Schema.Array( Schema.Struct({ - id: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + id: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -5898,7 +5898,7 @@ export const V1ListAllBackupsOutput = Schema.Struct({ ), physical_backup_data: Schema.Struct({ earliest_physical_backup_date_unix: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -5911,7 +5911,7 @@ export const V1ListAllBackupsOutput = Schema.Struct({ ), ), latest_physical_backup_date_unix: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -6039,15 +6039,15 @@ export const V1ListAllSnippetsOutput = Schema.Struct({ name: Schema.String, description: Schema.Union([Schema.String, Schema.Null]), project: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }), owner: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), username: Schema.String, }), updated_by: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), username: Schema.String, }), favorite: Schema.Boolean, @@ -6168,7 +6168,7 @@ export const V1ListJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -6230,7 +6230,7 @@ export const V1ListJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -6361,7 +6361,7 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ description: Schema.String, type: Schema.Literals(["fixed", "usage"]), interval: Schema.Literals(["monthly", "hourly"]), - amount: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + amount: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), meta: Schema.optionalKey(ListProjectAddonsResponseJsonValue), }), @@ -6416,7 +6416,7 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ description: Schema.String, type: Schema.Literals(["fixed", "usage"]), interval: Schema.Literals(["monthly", "hourly"]), - amount: Schema.Number.check( + amount: Schema.Finite.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), }), @@ -6478,14 +6478,14 @@ export const V1ModifyDatabaseDiskInput = Schema.Struct({ attributes: Schema.Union( [ Schema.Struct({ - iops: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + iops: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", }), ) .check(Schema.isGreaterThan(0).annotate({ expected: "a value greater than 0" })), - size_gb: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + size_gb: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -6493,7 +6493,7 @@ export const V1ModifyDatabaseDiskInput = Schema.Struct({ ) .check(Schema.isGreaterThan(0).annotate({ expected: "a value greater than 0" })), throughput_mibps: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -6504,14 +6504,14 @@ export const V1ModifyDatabaseDiskInput = Schema.Struct({ type: Schema.Literal("gp3"), }), Schema.Struct({ - iops: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + iops: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", }), ) .check(Schema.isGreaterThan(0).annotate({ expected: "a value greater than 0" })), - size_gb: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + size_gb: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isLessThanOrEqualTo(9007199254740991).annotate({ expected: "a value less than or equal to 9007199254740991", @@ -6841,7 +6841,7 @@ export const V1RestorePhysicalBackupInput = Schema.Struct({ expected: "a string matching the RegExp ^[a-z]+$", }), ), - id: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + id: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -6863,7 +6863,7 @@ export const V1RestorePitrBackupInput = Schema.Struct({ expected: "a string matching the RegExp ^[a-z]+$", }), ), - recovery_time_target_unix: Schema.Number.annotate({ format: "int64" }) + recovery_time_target_unix: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0" }), @@ -7053,7 +7053,7 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ is_default: Schema.Boolean, git_branch: Schema.optionalKey(Schema.String), pr_number: Schema.optionalKey( - Schema.Number.annotate({ format: "int32" }) + Schema.Finite.annotate({ format: "int32" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -7067,7 +7067,7 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ ), ), latest_check_run_id: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "This field is deprecated and will not be populated.", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -7143,7 +7143,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ slug: Schema.String, name: Schema.String, status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), - version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + version: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -7154,7 +7154,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - created_at: Schema.Number.annotate({ format: "int64" }) + created_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -7166,7 +7166,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - updated_at: Schema.Number.annotate({ format: "int64" }) + updated_at: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -7199,7 +7199,7 @@ export const V1UpdateAProjectInput = Schema.Struct({ ).check(Schema.isMaxLength(256).annotate({ expected: "a value with a length of at most 256" })), }); export const V1UpdateAProjectOutput = Schema.Struct({ - id: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + id: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -7359,7 +7359,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ disable_signup: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), jwt_exp: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7394,7 +7394,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ smtp_pass: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), smtp_max_frequency: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7498,7 +7498,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), mfa_max_enrolled_factors: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7537,7 +7537,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ security_captcha_secret: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), sessions_timebox: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", }), @@ -7547,7 +7547,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), sessions_inactivity_timeout: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })).check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", }), @@ -7571,7 +7571,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_anonymous_users: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7587,7 +7587,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_email_sent: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7603,7 +7603,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_sms_sent: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7619,7 +7619,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_verify: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7635,7 +7635,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_token_refresh: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7651,7 +7651,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_otp: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7667,7 +7667,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), rate_limit_web3: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -7688,7 +7688,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ password_hibp_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), password_min_length: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(6).annotate({ expected: "a value greater than or equal to 6", @@ -7719,7 +7719,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), security_refresh_token_reuse_interval: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7734,7 +7734,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ]), ), mailer_otp_exp: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7748,7 +7748,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), mailer_otp_length: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(6).annotate({ expected: "a value greater than or equal to 6", @@ -7763,7 +7763,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ sms_autoconfirm: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), sms_max_frequency: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7779,7 +7779,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), sms_otp_exp: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7794,7 +7794,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ]), ), sms_otp_length: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -7984,7 +7984,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ external_zoom_secret: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), db_max_pool_size: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8003,7 +8003,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), api_max_request_duration: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8029,7 +8029,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ mfa_phone_verify_enabled: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), mfa_phone_max_frequency: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -8045,7 +8045,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ ), mfa_phone_otp_length: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -8071,7 +8071,7 @@ export const V1UpdateAuthServiceConfigInput = Schema.Struct({ }); export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ api_max_request_duration: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8085,7 +8085,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), db_max_pool_size: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8221,7 +8221,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ hook_after_user_created_uri: Schema.Union([Schema.String, Schema.Null]), hook_after_user_created_secrets: Schema.Union([Schema.String, Schema.Null]), jwt_exp: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8236,7 +8236,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ]), mailer_allow_unverified_email_sign_ins: Schema.Union([Schema.Boolean, Schema.Null]), mailer_autoconfirm: Schema.Union([Schema.Boolean, Schema.Null]), - mailer_otp_exp: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + mailer_otp_exp: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8248,7 +8248,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ }), ), mailer_otp_length: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8308,7 +8308,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ mailer_notifications_identity_linked_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mailer_notifications_identity_unlinked_enabled: Schema.Union([Schema.Boolean, Schema.Null]), mfa_max_enrolled_factors: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8331,7 +8331,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ webauthn_rp_display_name: Schema.Union([Schema.String, Schema.Null]), webauthn_rp_id: Schema.Union([Schema.String, Schema.Null]), webauthn_rp_origins: Schema.Union([Schema.String, Schema.Null]), - mfa_phone_otp_length: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + mfa_phone_otp_length: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8344,7 +8344,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ), mfa_phone_template: Schema.Union([Schema.String, Schema.Null]), mfa_phone_max_frequency: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8362,7 +8362,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ nimbus_oauth_client_secret: Schema.Union([Schema.String, Schema.Null]), password_hibp_enabled: Schema.Union([Schema.Boolean, Schema.Null]), password_min_length: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8385,7 +8385,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_anonymous_users: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8399,7 +8399,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_email_sent: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8413,7 +8413,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_sms_sent: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8427,7 +8427,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_token_refresh: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8441,7 +8441,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_verify: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8455,7 +8455,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_otp: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8469,7 +8469,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), rate_limit_web3: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8496,7 +8496,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ security_captcha_secret: Schema.Union([Schema.String, Schema.Null]), security_manual_linking_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_refresh_token_reuse_interval: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8511,19 +8511,19 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ]), security_update_password_require_reauthentication: Schema.Union([Schema.Boolean, Schema.Null]), sessions_inactivity_timeout: Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), sessions_single_per_user: Schema.Union([Schema.Boolean, Schema.Null]), sessions_tags: Schema.Union([Schema.String, Schema.Null]), sessions_timebox: Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), site_url: Schema.Union([Schema.String, Schema.Null]), sms_autoconfirm: Schema.Union([Schema.Boolean, Schema.Null]), sms_max_frequency: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8539,7 +8539,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ sms_messagebird_access_key: Schema.Union([Schema.String, Schema.Null]), sms_messagebird_originator: Schema.Union([Schema.String, Schema.Null]), sms_otp_exp: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8552,7 +8552,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ), Schema.Null, ]), - sms_otp_length: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + sms_otp_length: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8604,7 +8604,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ]), smtp_host: Schema.Union([Schema.String, Schema.Null]), smtp_max_frequency: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -8626,7 +8626,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ oauth_server_allow_dynamic_registration: Schema.Boolean, oauth_server_authorization_path: Schema.Union([Schema.String, Schema.Null]), custom_oauth_enabled: Schema.Boolean, - custom_oauth_max_providers: Schema.Number.check( + custom_oauth_max_providers: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -8770,7 +8770,7 @@ export const V1UpdateJitAccessInput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -8831,7 +8831,7 @@ export const V1UpdateJitAccessOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), expires_at: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), allowed_networks: Schema.optionalKey( Schema.Struct({ @@ -8968,7 +8968,7 @@ export const V1UpdatePoolerConfigInput = Schema.Struct({ ), default_pool_size: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -8990,7 +8990,7 @@ export const V1UpdatePoolerConfigInput = Schema.Struct({ }); export const V1UpdatePoolerConfigOutput = Schema.Struct({ default_pool_size: Schema.Union([ - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -9043,7 +9043,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ maintenance_work_mem: Schema.optionalKey(Schema.String), track_activity_query_size: Schema.optionalKey(Schema.String), max_connections: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -9056,7 +9056,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_locks_per_transaction: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(10).annotate({ expected: "a value greater than or equal to 10", @@ -9069,7 +9069,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_logical_replication_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9082,7 +9082,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_parallel_maintenance_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9095,7 +9095,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_parallel_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9108,7 +9108,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_parallel_workers_per_gather: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9121,7 +9121,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_replication_slots: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -9137,7 +9137,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ max_standby_archive_delay: Schema.optionalKey(Schema.String), max_standby_streaming_delay: Schema.optionalKey(Schema.String), max_sync_workers_per_subscription: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9151,7 +9151,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), max_wal_size: Schema.optionalKey(Schema.String), max_wal_senders: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -9164,7 +9164,7 @@ export const V1UpdatePostgresConfigInput = Schema.Struct({ ), ), max_worker_processes: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9234,7 +9234,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ maintenance_work_mem: Schema.optionalKey(Schema.String), track_activity_query_size: Schema.optionalKey(Schema.String), max_connections: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -9247,7 +9247,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_locks_per_transaction: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(10).annotate({ expected: "a value greater than or equal to 10", @@ -9260,7 +9260,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_logical_replication_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9273,7 +9273,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_parallel_maintenance_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9286,7 +9286,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_parallel_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9299,7 +9299,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_parallel_workers_per_gather: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9312,7 +9312,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_replication_slots: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -9328,7 +9328,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ max_standby_archive_delay: Schema.optionalKey(Schema.String), max_standby_streaming_delay: Schema.optionalKey(Schema.String), max_sync_workers_per_subscription: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9342,7 +9342,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), max_wal_size: Schema.optionalKey(Schema.String), max_wal_senders: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -9355,7 +9355,7 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ ), ), max_worker_processes: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9408,7 +9408,7 @@ export const V1UpdatePostgrestServiceConfigInput = Schema.Struct({ db_extra_search_path: Schema.optionalKey(Schema.String), db_schema: Schema.optionalKey(Schema.String), max_rows: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9421,7 +9421,7 @@ export const V1UpdatePostgrestServiceConfigInput = Schema.Struct({ ), ), db_pool: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9434,7 +9434,7 @@ export const V1UpdatePostgrestServiceConfigInput = Schema.Struct({ ), ), db_pool_acquisition_timeout: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9447,7 +9447,7 @@ export const V1UpdatePostgrestServiceConfigInput = Schema.Struct({ }); export const V1UpdatePostgrestServiceConfigOutput = Schema.Struct({ db_schema: Schema.String, - max_rows: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_rows: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -9460,7 +9460,7 @@ export const V1UpdatePostgrestServiceConfigOutput = Schema.Struct({ ), db_extra_search_path: Schema.String, db_pool: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "If `null`, the value is automatically configured based on compute size.", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -9477,7 +9477,7 @@ export const V1UpdatePostgrestServiceConfigOutput = Schema.Struct({ Schema.Null, ]), db_pool_acquisition_timeout: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "If `null`, the value is automatically configured to 10.", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -9636,7 +9636,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ Schema.Boolean.annotate({ description: "Whether to only allow private channels" }), ), connection_pool: Schema.optionalKey( - Schema.Number.annotate({ description: "Sets connection pool size for Realtime Authorization" }) + Schema.Finite.annotate({ description: "Sets connection pool size for Realtime Authorization" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -9648,7 +9648,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), postgres_changes_pool: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets connection pool size used to create Postgres Changes subscriptions", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -9662,7 +9662,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_concurrent_users: Schema.optionalKey( - Schema.Number.annotate({ description: "Sets maximum number of concurrent users rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of concurrent users rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -9676,7 +9676,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_events_per_second: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets maximum number of events per second rate per channel limit", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -9692,7 +9692,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_bytes_per_second: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets maximum number of bytes per second rate per channel limit", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -9708,7 +9708,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_channels_per_client: Schema.optionalKey( - Schema.Number.annotate({ description: "Sets maximum number of channels per client rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of channels per client rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -9722,7 +9722,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_joins_per_second: Schema.optionalKey( - Schema.Number.annotate({ description: "Sets maximum number of joins per second rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of joins per second rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -9736,7 +9736,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_presence_events_per_second: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Sets maximum number of presence events per second rate limit", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -9752,7 +9752,7 @@ export const V1UpdateRealtimeConfigInput = Schema.Struct({ ), ), max_payload_size_in_kb: Schema.optionalKey( - Schema.Number.annotate({ description: "Sets maximum number of payload size in KB rate limit" }) + Schema.Finite.annotate({ description: "Sets maximum number of payload size in KB rate limit" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ @@ -9802,7 +9802,7 @@ export const V1UpdateStorageConfigInput = Schema.Struct({ }), ), fileSizeLimit: Schema.optionalKey( - Schema.Number.annotate({ format: "int64" }) + Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ @@ -9823,7 +9823,7 @@ export const V1UpdateStorageConfigInput = Schema.Struct({ icebergCatalog: Schema.optionalKey( Schema.Struct({ enabled: Schema.Boolean, - maxNamespaces: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxNamespaces: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9834,7 +9834,7 @@ export const V1UpdateStorageConfigInput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - maxTables: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxTables: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9845,7 +9845,7 @@ export const V1UpdateStorageConfigInput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - maxCatalogs: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxCatalogs: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9861,7 +9861,7 @@ export const V1UpdateStorageConfigInput = Schema.Struct({ vectorBuckets: Schema.optionalKey( Schema.Struct({ enabled: Schema.Boolean, - maxBuckets: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxBuckets: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -9872,7 +9872,7 @@ export const V1UpdateStorageConfigInput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - maxIndexes: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + maxIndexes: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10047,7 +10047,7 @@ export const V2CreateLogDrainInput = Schema.Struct({ password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), port: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), ), @@ -10082,7 +10082,7 @@ export const V2CreateLogDrainInput = Schema.Struct({ Schema.Struct({ host: Schema.optionalKey(Schema.String), port: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10134,7 +10134,7 @@ export const V2CreateLogDrainOutput = Schema.Struct({ password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), port: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), ), @@ -10169,7 +10169,7 @@ export const V2CreateLogDrainOutput = Schema.Struct({ Schema.Struct({ host: Schema.optionalKey(Schema.String), port: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10602,7 +10602,7 @@ export const V2DeployAWorkerInput = Schema.Struct({ runtime: Schema.optionalKey(Schema.String), size: Schema.String, exposure: Schema.String, - instances: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + instances: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10632,7 +10632,7 @@ export const V2DeployAWorkerOutput = Schema.Struct({ runtime: Schema.optionalKey(Schema.String), size: Schema.String, exposure: Schema.String, - instances: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + instances: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10651,7 +10651,7 @@ export const V2DeployAWorkerOutput = Schema.Struct({ deleting: Schema.optionalKey(Schema.Boolean), instances: Schema.optionalKey( Schema.Struct({ - declared: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + declared: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10662,7 +10662,7 @@ export const V2DeployAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - live: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + live: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10673,7 +10673,7 @@ export const V2DeployAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - ready: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ready: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10684,7 +10684,7 @@ export const V2DeployAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - stale: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + stale: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10726,7 +10726,7 @@ export const V2GetAWorkerOutput = Schema.Struct({ runtime: Schema.optionalKey(Schema.String), size: Schema.String, exposure: Schema.String, - instances: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + instances: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10745,7 +10745,7 @@ export const V2GetAWorkerOutput = Schema.Struct({ deleting: Schema.optionalKey(Schema.Boolean), instances: Schema.optionalKey( Schema.Struct({ - declared: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + declared: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10756,7 +10756,7 @@ export const V2GetAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - live: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + live: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10767,7 +10767,7 @@ export const V2GetAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - ready: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ready: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10778,7 +10778,7 @@ export const V2GetAWorkerOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - stale: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + stale: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10812,7 +10812,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ id: Schema.String.annotate({ description: "Project ref." }), attributes: Schema.Struct({ database: Schema.Struct({ - major_version: Schema.Number.annotate({ + major_version: Schema.Finite.annotate({ description: "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version.", }) @@ -10875,7 +10875,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ maintenance_work_mem: Schema.optionalKey(Schema.String), track_activity_query_size: Schema.optionalKey(Schema.String), max_connections: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -10888,7 +10888,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_locks_per_transaction: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(10).annotate({ expected: "a value greater than or equal to 10", @@ -10901,7 +10901,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_logical_replication_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10914,7 +10914,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_parallel_maintenance_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10927,7 +10927,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_parallel_workers: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10940,7 +10940,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_parallel_workers_per_gather: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10953,7 +10953,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_replication_slots: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10969,7 +10969,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ max_standby_archive_delay: Schema.optionalKey(Schema.String), max_standby_streaming_delay: Schema.optionalKey(Schema.String), max_sync_workers_per_subscription: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -10983,7 +10983,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), max_wal_size: Schema.optionalKey(Schema.String), max_wal_senders: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -10996,7 +10996,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), max_worker_processes: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -11055,7 +11055,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ pooler: Schema.Struct({ pool_mode: Schema.Literals(["transaction", "session", "statement"]), ignore_startup_parameters: Schema.String, - server_idle_timeout: Schema.Number.check( + server_idle_timeout: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11068,7 +11068,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - server_lifetime: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + server_lifetime: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11079,7 +11079,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - query_wait_timeout: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + query_wait_timeout: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11090,7 +11090,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - reserve_pool_size: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + reserve_pool_size: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11101,7 +11101,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - default_pool_size: Schema.Number.annotate({ + default_pool_size: Schema.Finite.annotate({ description: "Defaults to the pooler's size for the project's compute when not overridden.", }) @@ -11116,7 +11116,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_client_conn: Schema.Number.annotate({ + max_client_conn: Schema.Finite.annotate({ description: "Defaults to the pooler's size for the project's compute when not overridden.", }) @@ -11141,7 +11141,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ api: Schema.Struct({ db_schema: Schema.String.annotate({ description: "Schemas exposed through the Data API" }), db_extra_search_path: Schema.String, - max_rows: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_rows: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11152,7 +11152,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - db_pool_acquisition_timeout: Schema.Number.check( + db_pool_acquisition_timeout: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11166,7 +11166,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), ), db_pool: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here.", }) @@ -11186,7 +11186,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), realtime: Schema.Struct({ private_only: Schema.Boolean, - max_concurrent_users: Schema.Number.check( + max_concurrent_users: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11199,7 +11199,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_events_per_second: Schema.Number.check( + max_events_per_second: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11212,7 +11212,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_bytes_per_second: Schema.Number.check( + max_bytes_per_second: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11225,7 +11225,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_channels_per_client: Schema.Number.check( + max_channels_per_client: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11238,7 +11238,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_joins_per_second: Schema.Number.check( + max_joins_per_second: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11251,7 +11251,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_presence_events_per_second: Schema.Number.check( + max_presence_events_per_second: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11264,7 +11264,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_payload_size_in_kb: Schema.Number.check( + max_payload_size_in_kb: Schema.Finite.check( Schema.isInt().annotate({ expected: "an integer" }), ) .check( @@ -11279,7 +11279,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), presence_enabled: Schema.Boolean, suspend: Schema.Boolean, - connection_pool: Schema.Number.annotate({ + connection_pool: Schema.Finite.annotate({ description: "Defaults to Realtime's pool size for the project's compute when not overridden.", }) @@ -11295,7 +11295,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), ), postgres_changes_pool: Schema.Union([ - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "If `null`, no override is stored and Realtime applies its own default.", }) .check(Schema.isInt().annotate({ expected: "an integer" })) @@ -11313,7 +11313,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ]), }), storage: Schema.Struct({ - file_size_limit: Schema.Number.annotate({ format: "int64" }) + file_size_limit: Schema.Finite.annotate({ format: "int64" }) .check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -11331,7 +11331,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ purge_cache: Schema.Struct({ enabled: Schema.Boolean }), iceberg_catalog: Schema.Struct({ enabled: Schema.Boolean, - max_namespaces: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_namespaces: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11342,7 +11342,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_tables: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_tables: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11353,7 +11353,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_catalogs: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_catalogs: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11367,7 +11367,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), vector_buckets: Schema.Struct({ enabled: Schema.Boolean, - max_buckets: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_buckets: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11378,7 +11378,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - max_indexes: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + max_indexes: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11423,7 +11423,7 @@ export const V2ListAllWorkersOutput = Schema.Struct({ runtime: Schema.optionalKey(Schema.String), size: Schema.String, exposure: Schema.String, - instances: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + instances: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11442,7 +11442,7 @@ export const V2ListAllWorkersOutput = Schema.Struct({ deleting: Schema.optionalKey(Schema.Boolean), instances: Schema.optionalKey( Schema.Struct({ - declared: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + declared: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11453,7 +11453,7 @@ export const V2ListAllWorkersOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - live: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + live: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11464,7 +11464,7 @@ export const V2ListAllWorkersOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - ready: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + ready: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11475,7 +11475,7 @@ export const V2ListAllWorkersOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - stale: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + stale: Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ expected: "a value greater than or equal to -9007199254740991", @@ -11520,7 +11520,7 @@ export const V2ListLogDrainsOutput = Schema.Struct({ password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), port: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), ), @@ -11555,7 +11555,7 @@ export const V2ListLogDrainsOutput = Schema.Struct({ Schema.Struct({ host: Schema.optionalKey(Schema.String), port: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -11602,7 +11602,7 @@ export const V2ListOrganizationGithubConnectionsInput = Schema.Struct({ page: Schema.optionalKey( Schema.Struct({ size: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -11671,7 +11671,7 @@ export const V2ListOrganizationGithubConnectionsOutput = Schema.Struct({ attributes: Schema.Struct({ inserted_at: Schema.String.annotate({ description: "When the connection was created" }), updated_at: Schema.String.annotate({ description: "When the connection was last updated" }), - installation_id: Schema.Number.annotate({ + installation_id: Schema.Finite.annotate({ description: "GitHub App installation id", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), workdir: Schema.String.annotate({ @@ -11680,14 +11680,14 @@ export const V2ListOrganizationGithubConnectionsOutput = Schema.Struct({ supabase_changes_only: Schema.Boolean.annotate({ description: "Whether branches are only created for changes under `supabase/`", }), - branch_limit: Schema.Number.annotate({ + branch_limit: Schema.Finite.annotate({ description: "Maximum number of preview branches", }).check(Schema.isFinite().annotate({ expected: "a finite number" })), new_branch_per_pr: Schema.Boolean.annotate({ description: "Whether a preview branch is created for every pull request", }), project: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ref: Schema.String.annotate({ description: "Project ref" }) .check( Schema.isMinLength(20).annotate({ expected: "a value with a length of at least 20" }), @@ -11703,12 +11703,12 @@ export const V2ListOrganizationGithubConnectionsOutput = Schema.Struct({ name: Schema.String, }).annotate({ description: "The connected Supabase project" }), repository: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }).annotate({ description: "The connected GitHub repository" }), user: Schema.Union([ Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + id: Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), username: Schema.String, primary_email: Schema.Union([Schema.String, Schema.Null]), }).annotate({ description: "The user who created the connection, if still known" }), @@ -11749,7 +11749,7 @@ export const V2ListOrganizationMembersInput = Schema.Struct({ page: Schema.optionalKey( Schema.Struct({ size: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -11892,7 +11892,7 @@ export const V2ListOrganizationProjectsInput = Schema.Struct({ page: Schema.optionalKey( Schema.Struct({ size: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(1).annotate({ expected: "a value greater than or equal to 1", @@ -12006,11 +12006,11 @@ export const V2ListOrganizationProjectsOutput = Schema.Struct({ ]), ), disk_volume_size_gb: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), disk_throughput_mbps: Schema.optionalKey( - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), disk_last_modified_at: Schema.optionalKey(Schema.String), }), @@ -12216,7 +12216,7 @@ export const V2UpdateLogDrainInput = Schema.Struct({ password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), port: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), ), @@ -12251,7 +12251,7 @@ export const V2UpdateLogDrainInput = Schema.Struct({ Schema.Struct({ host: Schema.optionalKey(Schema.String), port: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", @@ -12304,7 +12304,7 @@ export const V2UpdateLogDrainOutput = Schema.Struct({ password: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), port: Schema.optionalKey( Schema.Union([ - Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + Schema.Finite.check(Schema.isFinite().annotate({ expected: "a finite number" })), Schema.Null, ]), ), @@ -12339,7 +12339,7 @@ export const V2UpdateLogDrainOutput = Schema.Struct({ Schema.Struct({ host: Schema.optionalKey(Schema.String), port: Schema.optionalKey( - Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) + Schema.Finite.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "a value greater than or equal to 0", diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 6aa2928b20..59c63ae3a5 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Context } from "effect"; +import { Data, Effect, Option, Context } from "effect"; import * as Cause from "effect/Cause"; import * as Redacted from "effect/Redacted"; import type { SchemaError } from "effect/Schema"; @@ -74,12 +74,11 @@ export class SupabaseApiClient extends Context.Service<SupabaseApiClient, Supaba "@supabase/api/SupabaseApiClient", ) {} -export class SupabaseApiConfigError extends Error { - readonly _tag = "SupabaseApiConfigError"; - +export class SupabaseApiConfigError extends Data.TaggedError("SupabaseApiConfigError")<{ + readonly message: string; +}> { constructor(message: string) { - super(message); - this.name = "SupabaseApiConfigError"; + super({ message }); } } @@ -93,8 +92,10 @@ export type SupabaseApiInputErrorSource = "generated_client" | "user_input"; * `user_input` without inspecting the schema error message. The original * schema failure is preserved as `cause`. */ -export class SupabaseApiInputError extends Error { - readonly _tag = "SupabaseApiInputError"; +export class SupabaseApiInputError extends Data.TaggedError("SupabaseApiInputError")<{ + readonly message: string; + readonly cause?: unknown; +}> { #source: SupabaseApiInputErrorSource = "generated_client"; get source(): SupabaseApiInputErrorSource { @@ -102,8 +103,7 @@ export class SupabaseApiInputError extends Error { } constructor(message: string, options?: { readonly cause?: unknown }) { - super(message, options); - this.name = "SupabaseApiInputError"; + super({ message, cause: options?.cause }); } static markAsUserInput<T extends SupabaseApiInputError>(error: T): T { @@ -125,10 +125,8 @@ function resolveSupabaseApiConfig( const accessToken = config.accessToken ?? Option.getOrUndefined(apiConfig.accessToken); if (accessToken === undefined) { - return yield* Effect.fail( - new SupabaseApiConfigError( - "Missing access token. Provide `accessToken` or set `SUPABASE_ACCESS_TOKEN`.", - ), + return yield* new SupabaseApiConfigError( + "Missing access token. Provide `accessToken` or set `SUPABASE_ACCESS_TOKEN`.", ); } @@ -367,9 +365,9 @@ function asBinaryRequestBody(value: unknown): Effect.Effect<Uint8Array, HttpBody } if (revealed instanceof Blob) { return Effect.tryPromise({ - try: async () => new Uint8Array(await revealed.arrayBuffer()), + try: (_signal) => revealed.arrayBuffer(), catch: (cause) => new HttpBody.HttpBodyError({ reason: { _tag: "JsonError" }, cause }), - }); + }).pipe(Effect.map((bytes) => new Uint8Array(bytes))); } return Effect.succeed(new TextEncoder().encode(String(revealed))); } @@ -551,7 +549,7 @@ export function makeSupabaseApiClient( return { execute: (definition, input) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + const validated = yield* Schema.decodeEffect(definition.inputSchema)(input).pipe( Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), ); const response = yield* executeRequest(prepared, definition, validated); @@ -568,7 +566,7 @@ export function makeSupabaseApiClient( }), executeRaw: (definition, input, headers) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( + const validated = yield* Schema.decodeEffect(definition.inputSchema)(input).pipe( Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), ); const request = yield* buildRequest(definition, validated).pipe( diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 893dd619b4..3da55bbec2 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -121,15 +121,10 @@ function formDataTextValue(formData: FormData, key: string): string { return value; } -async function formDataFileTexts(formData: FormData, key: string): Promise<Array<string>> { +function formDataFileTexts(formData: FormData, key: string): Promise<Array<string>> { const values = formData.getAll(key); return Promise.all( - values.map(async (value) => { - if (typeof value === "string") { - return value; - } - return value.text(); - }), + values.map((value) => (typeof value === "string" ? Promise.resolve(value) : value.text())), ); } @@ -160,837 +155,855 @@ const config = { } as const; describe("makeSupabaseApiClient", () => { - test("defaults request-schema failures to generated-client provenance", async () => { - let requests = 0; - const client = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - requests += 1; - return Effect.succeed(jsonResponse(request, 200, {})); - }), - ), - ), - ); - - const executeError = await Effect.runPromise( - client - .execute(operationDefinitions.v1DeleteAFunction, { - ref: "invalid-ref", - function_slug: "hello-world", - }) - .pipe(Effect.flip), - ); - const executeRawError = await Effect.runPromise( - client - .executeRaw(operationDefinitions.v1DeleteAFunction, { - ref: "invalid-ref", - function_slug: "hello-world", - }) - .pipe(Effect.flip), - ); - - for (const error of [executeError, executeRawError]) { - expect(error).toBeInstanceOf(SupabaseApiInputError); - if (!(error instanceof SupabaseApiInputError)) { - throw new Error("expected SupabaseApiInputError"); - } - expect(error.source).toBe("generated_client"); - } - - if (!(executeRawError instanceof SupabaseApiInputError)) { - throw new Error("expected SupabaseApiInputError"); - } - expect(markSupabaseApiInputErrorAsUserInput(executeRawError)).toBe(executeRawError); - expect(executeRawError.source).toBe("user_input"); - expect(requests).toBe(0); - }); - - test("fails request-body construction before sending a request", async () => { - class BrokenBlob extends Blob { - override arrayBuffer(): Promise<ArrayBuffer> { - return Promise.reject(new Error("body read failed")); - } - } - - let requests = 0; - const error = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.executeRaw(operationDefinitions.v1CreateAFunction, { - ref: "abcdefghijklmnopqrst", - slug: "demo", - body: new BrokenBlob([]), - }), - ), - Effect.provide( - httpClientLayer((request) => { - requests += 1; - return Effect.succeed(functionResponse(request, 201)); - }), - ), - Effect.flip, - ), - ); - - expect(error).toBeInstanceOf(HttpBody.HttpBodyError); - expect(requests).toBe(0); - }); + test("defaults request-schema failures to generated-client provenance", () => + Effect.runPromise( + Effect.gen(function* () { + let requests = 0; + const client = yield* makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ); + + const executeError = yield* client + .execute(operationDefinitions.v1DeleteAFunction, { + ref: "invalid-ref", + function_slug: "hello-world", + }) + .pipe(Effect.flip); + const executeRawError = yield* client + .executeRaw(operationDefinitions.v1DeleteAFunction, { + ref: "invalid-ref", + function_slug: "hello-world", + }) + .pipe(Effect.flip); + + for (const error of [executeError, executeRawError]) { + expect(error).toBeInstanceOf(SupabaseApiInputError); + if (!(error instanceof SupabaseApiInputError)) { + throw new Error("expected SupabaseApiInputError"); + } + expect(error.source).toBe("generated_client"); + } - test("retries transport errors for POST requests", async () => { - let attempts = 0; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - if (attempts < 3) { - return Effect.fail(transportError(request, "socket reset")); - } + if (!(executeRawError instanceof SupabaseApiInputError)) { + throw new Error("expected SupabaseApiInputError"); + } + expect(markSupabaseApiInputErrorAsUserInput(executeRawError)).toBe(executeRawError); + expect(executeRawError.source).toBe("user_input"); + expect(requests).toBe(0); + }), + )); + + test("fails request-body construction before sending a request", () => + Effect.runPromise( + Effect.gen(function* () { + class BrokenBlob extends Blob { + override arrayBuffer(): Promise<ArrayBuffer> { + return Promise.reject(new Error("body read failed")); + } + } - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - }), - ); - }), - ), - ), - ); - - expect(attempts).toBe(3); - expect(result.ref).toBe("abcdefghijklmnopqrst"); - }); + let requests = 0; + const error = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw(operationDefinitions.v1CreateAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + body: new BrokenBlob([]), + }), + ), + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(functionResponse(request, 201)); + }), + ), + Effect.flip, + ); - test("reveals redacted auth tokens only at the transport boundary", async () => { - let authorizationHeader: string | undefined; - - const result = await Effect.runPromise( - makeSupabaseApiClient({ - ...config, - accessToken: Redacted.make("redacted-token"), - }).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.provide( - httpClientLayer((request) => { - authorizationHeader = request.headers.authorization; - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - }), - ); - }), - ), - ), - ); - - expect(authorizationHeader).toBe("Bearer redacted-token"); - expect(result.ref).toBe("abcdefghijklmnopqrst"); - }); + expect(error).toBeInstanceOf(HttpBody.HttpBodyError); + expect(requests).toBe(0); + }), + )); + + test("retries transport errors for POST requests", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + if (attempts < 3) { + return Effect.fail(transportError(request, "socket reset")); + } - test("applies default headers alongside auth, user agent, and request headers", async () => { - let seenHeaders: - | { - authorization: string | undefined; - userAgent: string | undefined; - command: string | undefined; - commandRunId: string | undefined; - idempotencyKey: string | undefined; - } - | undefined; - - await Effect.runPromise( - makeSupabaseApiClient({ - ...config, - headers: { - "X-Supabase-Command": "branches list", - "X-Supabase-Command-Run-ID": "run-123", - }, - }).pipe( - Effect.flatMap((client) => - client.execute<"v1ApplyAMigration">(operationDefinitions.v1ApplyAMigration, { - ref: "abcdefghijklmnopqrst", - query: "select 1", - name: "smoke_test", - "Idempotency-Key": "migration-123", - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenHeaders = { - authorization: request.headers.authorization, - userAgent: request.headers["user-agent"], - command: request.headers["x-supabase-command"], - commandRunId: request.headers["x-supabase-command-run-id"], - idempotencyKey: request.headers["idempotency-key"], - }; - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), - ); - }), - ), - ), - ); - - expect(seenHeaders).toEqual({ - authorization: "Bearer test-token", - userAgent: "supabase-api/test", - command: "branches list", - commandRunId: "run-123", - idempotencyKey: "migration-123", - }); - }); + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }), + ); + }), + ), + ); - test("retries 5xx responses for idempotent GET requests", async () => { - let attempts = 0; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProject">(operationDefinitions.v1GetProject, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - if (attempts === 1) { + expect(attempts).toBe(3); + expect(result.ref).toBe("abcdefghijklmnopqrst"); + }), + )); + + test("reveals redacted auth tokens only at the transport boundary", () => + Effect.runPromise( + Effect.gen(function* () { + let authorizationHeader: string | undefined; + + const result = yield* makeSupabaseApiClient({ + ...config, + accessToken: Redacted.make("redacted-token"), + }).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.provide( + httpClientLayer((request) => { + authorizationHeader = request.headers.authorization; return Effect.succeed( - jsonResponse(request, 500, { - error: "temporary failure", + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", }), ); + }), + ), + ); + + expect(authorizationHeader).toBe("Bearer redacted-token"); + expect(result.ref).toBe("abcdefghijklmnopqrst"); + }), + )); + + test("applies default headers alongside auth, user agent, and request headers", () => + Effect.runPromise( + Effect.gen(function* () { + let seenHeaders: + | { + authorization: string | undefined; + userAgent: string | undefined; + command: string | undefined; + commandRunId: string | undefined; + idempotencyKey: string | undefined; } + | undefined; - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - expect(attempts).toBe(2); - expect(result.database.host).toBe("db.supabase.internal"); - }); + yield* makeSupabaseApiClient({ + ...config, + headers: { + "X-Supabase-Command": "branches list", + "X-Supabase-Command-Run-ID": "run-123", + }, + }).pipe( + Effect.flatMap((client) => + client.execute<"v1ApplyAMigration">(operationDefinitions.v1ApplyAMigration, { + ref: "abcdefghijklmnopqrst", + query: "select 1", + name: "smoke_test", + "Idempotency-Key": "migration-123", + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenHeaders = { + authorization: request.headers.authorization, + userAgent: request.headers["user-agent"], + command: request.headers["x-supabase-command"], + commandRunId: request.headers["x-supabase-command-run-id"], + idempotencyKey: request.headers["idempotency-key"], + }; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), + ); + }), + ), + ); + + expect(seenHeaders).toEqual({ + authorization: "Bearer test-token", + userAgent: "supabase-api/test", + command: "branches list", + commandRunId: "run-123", + idempotencyKey: "migration-123", + }); + }), + )); - test("decodes nullable JWT templates in API key responses", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { - ref: "abcdefghijklmnopqrst", - reveal: true, - }), - ), - Effect.provide( - httpClientLayer((request) => { - const url = requestUrl(request); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/api-keys"); - expect(requestUrlParam(request, "reveal")).toBe("true"); - return Effect.succeed( - jsonResponse(request, 200, [ - { - name: "anon", - type: "legacy", - api_key: "anon-key", - secret_jwt_template: null, - }, - { - name: "service_role", - type: "secret", - api_key: "service-role-key", - secret_jwt_template: { role: "service_role" }, - }, - ]), - ); - }), - ), - ), - ); - - expect(result).toEqual([ - { - name: "anon", - type: "legacy", - api_key: "anon-key", - secret_jwt_template: null, - }, - { - name: "service_role", - type: "secret", - api_key: "service-role-key", - secret_jwt_template: { role: "service_role" }, - }, - ]); - }); + test("retries 5xx responses for idempotent GET requests", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProject">(operationDefinitions.v1GetProject, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + if (attempts === 1) { + return Effect.succeed( + jsonResponse(request, 500, { + error: "temporary failure", + }), + ); + } + + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, + }), + ); + }), + ), + ); + + expect(attempts).toBe(2); + expect(result.database.host).toBe("db.supabase.internal"); + }), + )); + + test("decodes nullable JWT templates in API key responses", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { + ref: "abcdefghijklmnopqrst", + reveal: true, + }), + ), + Effect.provide( + httpClientLayer((request) => { + const url = requestUrl(request); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/api-keys"); + expect(requestUrlParam(request, "reveal")).toBe("true"); + return Effect.succeed( + jsonResponse(request, 200, [ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + secret_jwt_template: null, + }, + { + name: "service_role", + type: "secret", + api_key: "service-role-key", + secret_jwt_template: { role: "service_role" }, + }, + ]), + ); + }), + ), + ); + + expect(result).toEqual([ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + secret_jwt_template: null, + }, + { + name: "service_role", + type: "secret", + api_key: "service-role-key", + secret_jwt_template: { role: "service_role" }, + }, + ]); + }), + )); // Both payloads are the shapes reported against 2.112.0, where the spec's // Z-anchored pattern rejected them and broke `link` and `branches list` // outright (supabase/cli#6115). - test("decodes timestamps with a numeric UTC offset", async () => { - const apiKeys = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, [ - { - name: "anon", - type: "legacy", - api_key: "anon-key", - inserted_at: "2026-05-01T08:00:00+00:00", - updated_at: "2026-05-01T08:00:00.123456+02:00", - }, - ]), - ), + test("decodes timestamps with a numeric UTC offset", () => + Effect.runPromise( + Effect.gen(function* () { + const apiKeys = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { + ref: "abcdefghijklmnopqrst", + }), ), - ), - ), - ); - - expect(apiKeys[0]?.inserted_at).toBe("2026-05-01T08:00:00+00:00"); - expect(apiKeys[0]?.updated_at).toBe("2026-05-01T08:00:00.123456+02:00"); - - const branches = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1ListAllBranches">(operationDefinitions.v1ListAllBranches, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, [ - { - id: "6f8f9d2c-1f43-4b8a-9d0e-3a2b1c4d5e6f", - name: "preview", - project_ref: "abcdefghijklmnopqrst", - parent_project_ref: "tsrqponmlkjihgfedcba", - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - with_data: false, - created_at: "2026-08-06T19:27:30.261795+00:00", - updated_at: "2026-08-06T19:27:30.261795+00:00", - }, - ]), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, [ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + inserted_at: "2026-05-01T08:00:00+00:00", + updated_at: "2026-05-01T08:00:00.123456+02:00", + }, + ]), + ), ), ), - ), - ), - ); + ); - expect(branches[0]?.created_at).toBe("2026-08-06T19:27:30.261795+00:00"); - }); + expect(apiKeys[0]?.inserted_at).toBe("2026-05-01T08:00:00+00:00"); + expect(apiKeys[0]?.updated_at).toBe("2026-05-01T08:00:00.123456+02:00"); - test("accepts missing custom-hostname SSL validation records", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - status: "2_initiated", - custom_hostname: "shop.acme.dev", - data: { - success: true, - errors: [], - messages: [], - result: { - id: "hostname-id", - hostname: "shop.acme.dev", - ssl: { - status: "pending_validation", - }, - ownership_verification: { - type: "txt", - name: "_cf-custom-hostname.shop.acme.dev", - value: "verification-token", - }, - custom_origin_server: "abcdefghijklmnopqrst.supabase.co", - status: "pending", + const branches = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ListAllBranches">(operationDefinitions.v1ListAllBranches, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, [ + { + id: "6f8f9d2c-1f43-4b8a-9d0e-3a2b1c4d5e6f", + name: "preview", + project_ref: "abcdefghijklmnopqrst", + parent_project_ref: "tsrqponmlkjihgfedcba", + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + with_data: false, + created_at: "2026-08-06T19:27:30.261795+00:00", + updated_at: "2026-08-06T19:27:30.261795+00:00", }, - }, - }), + ]), + ), ), ), - ), - ), - ); + ); - expect(result.data.result.ssl.validation_records).toBeUndefined(); - }); + expect(branches[0]?.created_at).toBe("2026-08-06T19:27:30.261795+00:00"); + }), + )); + + test("accepts missing custom-hostname SSL validation records", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + status: "2_initiated", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "hostname-id", + hostname: "shop.acme.dev", + ssl: { + status: "pending_validation", + }, + ownership_verification: { + type: "txt", + name: "_cf-custom-hostname.shop.acme.dev", + value: "verification-token", + }, + custom_origin_server: "abcdefghijklmnopqrst.supabase.co", + status: "pending", + }, + }, + }), + ), + ), + ), + ); - test("accepts missing custom-hostname ownership verification", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - status: "4_origin_setup_completed", - custom_hostname: "shop.acme.dev", - data: { - success: true, - errors: [], - messages: [], - result: { - id: "hostname-id", - hostname: "shop.acme.dev", - ssl: { + expect(result.data.result.ssl.validation_records).toBeUndefined(); + }), + )); + + test("accepts missing custom-hostname ownership verification", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + status: "4_origin_setup_completed", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "hostname-id", + hostname: "shop.acme.dev", + ssl: { + status: "active", + }, + custom_origin_server: "abcdefghijklmnopqrst.supabase.co", status: "active", }, - custom_origin_server: "abcdefghijklmnopqrst.supabase.co", - status: "active", }, - }, - }), + }), + ), ), ), - ), - ), - ); + ); - expect(result.data.result.ownership_verification).toBeUndefined(); - expect(result.data.result.ssl.validation_records).toBeUndefined(); - }); - - test("accepts processing custom-hostname responses without top-level status or hostname", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - data: { - success: true, - errors: [], - messages: [], - result: { - id: "hostname-id", - hostname: "shop.acme.dev", - ssl: { - status: "initializing", - }, - ownership_verification: { - type: "txt", - name: "_cf-custom-hostname.shop.acme.dev", - value: "verification-token", + expect(result.data.result.ownership_verification).toBeUndefined(); + expect(result.data.result.ssl.validation_records).toBeUndefined(); + }), + )); + + test("accepts processing custom-hostname responses without top-level status or hostname", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + data: { + success: true, + errors: [], + messages: [], + result: { + id: "hostname-id", + hostname: "shop.acme.dev", + ssl: { + status: "initializing", + }, + ownership_verification: { + type: "txt", + name: "_cf-custom-hostname.shop.acme.dev", + value: "verification-token", + }, + custom_origin_server: "abcdefghijklmnopqrst.supabase.co", + status: "pending", }, - custom_origin_server: "abcdefghijklmnopqrst.supabase.co", - status: "pending", }, - }, - }), + }), + ), ), ), - ), - ), - ); + ); - expect(result.status).toBeUndefined(); - expect(result.custom_hostname).toBeUndefined(); - expect(result.data.result.ssl.validation_records).toBeUndefined(); - }); + expect(result.status).toBeUndefined(); + expect(result.custom_hostname).toBeUndefined(); + expect(result.data.result.ssl.validation_records).toBeUndefined(); + }), + )); + + test("does not retry 5xx responses for POST requests", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; + + const exit = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + return Effect.succeed( + jsonResponse(request, 500, { + error: "do not retry post", + }), + ); + }), + ), + ); - test("does not retry 5xx responses for POST requests", async () => { - let attempts = 0; - - const exit = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return Effect.succeed( - jsonResponse(request, 500, { - error: "do not retry post", - }), - ); - }), - ), - ), - ); - - expect(attempts).toBe(1); - expect(Exit.isFailure(exit)).toBe(true); - }); + expect(attempts).toBe(1); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - test("stops after the configured number of transport retries", async () => { - let attempts = 0; + test("stops after the configured number of transport retries", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; - const exit = await Effect.runPromise( - makeSupabaseApiClient(config, { - retry: { - maxRetries: 2, - }, - }).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return Effect.fail(transportError(request, "still broken")); - }), - ), - ), - ); - - expect(attempts).toBe(3); - expect(Exit.isFailure(exit)).toBe(true); - }); + const exit = yield* makeSupabaseApiClient(config, { + retry: { + maxRetries: 2, + }, + }).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + return Effect.fail(transportError(request, "still broken")); + }), + ), + ); - test("decodes text responses through the unified execute path", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, { - // 20-letter project ref. Used to be "branch-ref" but the UUID - // branch of the oneOf union now has an actual pattern check, so - // free-form strings like "branch-ref" no longer match either - // branch. - branch_id_or_ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("select * from test;", { - status: 200, - headers: { - "content-type": "text/plain", - }, - }), + expect(attempts).toBe(3); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); + + test("decodes text responses through the unified execute path", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, { + // 20-letter project ref. Used to be "branch-ref" but the UUID + // branch of the oneOf union now has an actual pattern check, so + // free-form strings like "branch-ref" no longer match either + // branch. + branch_id_or_ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("select * from test;", { + status: 200, + headers: { + "content-type": "text/plain", + }, + }), + ), ), ), ), - ), - ), - ); - - expect(result).toBe("select * from test;"); - }); + ); - test("decodes void responses through the unified execute path", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1DisablePreviewBranching">( - operationDefinitions.v1DisablePreviewBranching, - { - ref: "abcdefghijklmnopqrst", - }, + expect(result).toBe("select * from test;"); + }), + )); + + test("decodes void responses through the unified execute path", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1DisablePreviewBranching">( + operationDefinitions.v1DisablePreviewBranching, + { + ref: "abcdefghijklmnopqrst", + }, + ), ), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(null, { - status: 204, - }), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: 204, + }), + ), ), ), ), - ), - ), - ); + ); - expect(result).toBeUndefined(); - }); + expect(result).toBeUndefined(); + }), + )); + + test("serializes oauth token exchange bodies as x-www-form-urlencoded", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { + body: { + grant_type: "authorization_code", + client_id: "11111111-1111-4111-8111-111111111111", + client_secret: "client-secret", + code: "auth-code", + code_verifier: "code-verifier", + redirect_uri: "https://example.com/callback", + resource: "https://mcp.supabase.com", + }, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(oauthTokenResponse(request)); + }), + ), + ); + + expect(result.access_token).toBe("access-token"); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.headers["content-type"]).toBe("application/x-www-form-urlencoded"); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/oauth/token"); + expect(Array.from(url.searchParams.keys())).toEqual([]); + + const body = new URLSearchParams(requestBodyText(seenRequest!)); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("client_id")).toBe("11111111-1111-4111-8111-111111111111"); + expect(body.get("client_secret")).toBe("client-secret"); + expect(body.get("code")).toBe("auth-code"); + expect(body.get("code_verifier")).toBe("code-verifier"); + expect(body.get("redirect_uri")).toBe("https://example.com/callback"); + expect(body.get("resource")).toBe("https://mcp.supabase.com"); + expect(body.has("refresh_token")).toBe(false); + expect(body.has("scope")).toBe(false); + }), + )); + + test("serializes refresh-token exchange bodies without omitted oauth fields", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { + body: { + grant_type: "refresh_token", + refresh_token: "refresh-token", + scope: "read:projects", + }, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(oauthTokenResponse(request)); + }), + ), + ); - test("serializes oauth token exchange bodies as x-www-form-urlencoded", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { - body: { - grant_type: "authorization_code", - client_id: "11111111-1111-4111-8111-111111111111", - client_secret: "client-secret", - code: "auth-code", - code_verifier: "code-verifier", - redirect_uri: "https://example.com/callback", - resource: "https://mcp.supabase.com", - }, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(oauthTokenResponse(request)); - }), - ), - ), - ); - - expect(result.access_token).toBe("access-token"); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.headers["content-type"]).toBe("application/x-www-form-urlencoded"); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/oauth/token"); - expect(Array.from(url.searchParams.keys())).toEqual([]); - - const body = new URLSearchParams(requestBodyText(seenRequest!)); - expect(body.get("grant_type")).toBe("authorization_code"); - expect(body.get("client_id")).toBe("11111111-1111-4111-8111-111111111111"); - expect(body.get("client_secret")).toBe("client-secret"); - expect(body.get("code")).toBe("auth-code"); - expect(body.get("code_verifier")).toBe("code-verifier"); - expect(body.get("redirect_uri")).toBe("https://example.com/callback"); - expect(body.get("resource")).toBe("https://mcp.supabase.com"); - expect(body.has("refresh_token")).toBe(false); - expect(body.has("scope")).toBe(false); - }); + expect(result.refresh_token).toBe("refresh-token"); - test("serializes refresh-token exchange bodies without omitted oauth fields", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { - body: { - grant_type: "refresh_token", - refresh_token: "refresh-token", - scope: "read:projects", - }, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(oauthTokenResponse(request)); - }), - ), - ), - ); - - expect(result.refresh_token).toBe("refresh-token"); - - const body = new URLSearchParams(requestBodyText(seenRequest!)); - expect(body.get("grant_type")).toBe("refresh_token"); - expect(body.get("refresh_token")).toBe("refresh-token"); - expect(body.get("scope")).toBe("read:projects"); - expect(body.has("code")).toBe(false); - expect(body.has("client_id")).toBe(false); - }); + const body = new URLSearchParams(requestBodyText(seenRequest!)); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh-token"); + expect(body.get("scope")).toBe("read:projects"); + expect(body.has("code")).toBe(false); + expect(body.has("client_id")).toBe(false); + }), + )); - test("serializes create function requests as eszip bodies with metadata query params", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - const body = new TextEncoder().encode("console.log('deploy create');"); - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAFunction">(operationDefinitions.v1CreateAFunction, { - ref: "abcdefghijklmnopqrst", - slug: "demo", - name: "Demo Function", - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - ezbr_sha256: "abc123", - body, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(functionResponse(request, 201)); - }), - ), - ), - ); - - expect(result.slug).toBe("demo"); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); - expect(requestBodyBytes(seenRequest!)).toEqual(body); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions"); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); - expect(requestUrlParam(seenRequest!, "name")).toBe("Demo Function"); - expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); - expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); - expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); - expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("abc123"); - }); + test("serializes create function requests as eszip bodies with metadata query params", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + const body = new TextEncoder().encode("console.log('deploy create');"); - test("serializes update function requests as eszip bodies with metadata query params", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - const body = new TextEncoder().encode("console.log('deploy update');").buffer; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1UpdateAFunction">(operationDefinitions.v1UpdateAFunction, { - ref: "abcdefghijklmnopqrst", - function_slug: "demo", - slug: "demo-renamed", - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - ezbr_sha256: "def456", - body, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(functionResponse(request, 200)); - }), - ), - ), - ); - - expect(result.slug).toBe("demo"); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); - expect(requestBodyBytes(seenRequest!)).toEqual(new Uint8Array(body)); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/demo"); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo-renamed"); - expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); - expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); - expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); - expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("def456"); - }); + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAFunction">(operationDefinitions.v1CreateAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + name: "Demo Function", + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + ezbr_sha256: "abc123", + body, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(functionResponse(request, 201)); + }), + ), + ); + + expect(result.slug).toBe("demo"); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); + expect(requestBodyBytes(seenRequest!)).toEqual(body); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions"); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); + expect(requestUrlParam(seenRequest!, "name")).toBe("Demo Function"); + expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); + expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); + expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); + expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("abc123"); + }), + )); - test("serializes deploy function requests as multipart bodies with json metadata", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const metadata = { - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - static_patterns: ["functions/demo/static/**/*.js"], - verify_jwt: true, - name: "demo", - } as const; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1DeployAFunction">(operationDefinitions.v1DeployAFunction, { - ref: "abcdefghijklmnopqrst", - slug: "demo", - bundleOnly: true, - body: { - metadata, - file: [new Uint8Array([1, 2, 3]), new Blob(["deno-config"])], - }, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(deployFunctionResponse(request)); - }), - ), - ), - ); - - expect(result.slug).toBe("demo"); - expect(seenRequest).toBeDefined(); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/deploy"); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); - expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); - - const formData = requestFormData(seenRequest!); - expect(JSON.parse(formDataTextValue(formData, "metadata"))).toEqual(metadata); - expect(await formDataFileTexts(formData, "file")).toEqual([ - "\u0001\u0002\u0003", - "deno-config", - ]); - }); + test("serializes update function requests as eszip bodies with metadata query params", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + const body = new TextEncoder().encode("console.log('deploy update');").buffer; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1UpdateAFunction">(operationDefinitions.v1UpdateAFunction, { + ref: "abcdefghijklmnopqrst", + function_slug: "demo", + slug: "demo-renamed", + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + ezbr_sha256: "def456", + body, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(functionResponse(request, 200)); + }), + ), + ); + + expect(result.slug).toBe("demo"); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); + expect(requestBodyBytes(seenRequest!)).toEqual(new Uint8Array(body)); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/demo"); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo-renamed"); + expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); + expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); + expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); + expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("def456"); + }), + )); + + test("serializes deploy function requests as multipart bodies with json metadata", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const metadata = { + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + static_patterns: ["functions/demo/static/**/*.js"], + verify_jwt: true, + name: "demo", + } as const; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1DeployAFunction">(operationDefinitions.v1DeployAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + bundleOnly: true, + body: { + metadata, + file: [new Uint8Array([1, 2, 3]), new Blob(["deno-config"])], + }, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(deployFunctionResponse(request)); + }), + ), + ); + + expect(result.slug).toBe("demo"); + expect(seenRequest).toBeDefined(); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/deploy"); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); + expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); + + const formData = requestFormData(seenRequest!); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + formDataTextValue(formData, "metadata"), + ), + ).toEqual(metadata); + expect(yield* Effect.promise(() => formDataFileTexts(formData, "file"))).toEqual([ + "\u0001\u0002\u0003", + "deno-config", + ]); + }), + )); test("rejects string raw binary bodies at schema decode time", () => { expect(() => @@ -1017,132 +1030,132 @@ describe("makeSupabaseApiClient", () => { ).toThrow(); }); - test("surfaces a 404 on a v2 operation as a distinguishable status error and wires the request identically to v1", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const client = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed( - jsonResponse(request, 404, { message: "Organization not found" }), - ); - }), - ), - ), - ); - - const error = await Effect.runPromise( - client - .execute(operationDefinitions.v2ListOrganizationMembers, { slug: "my-org" }) - .pipe(Effect.flip), - ); - - expect(HttpClientError.isHttpClientError(error)).toBe(true); - if (!HttpClientError.isHttpClientError(error)) { - throw new Error("expected HttpClientError"); - } - expect(error.reason._tag).toBe("StatusCodeError"); - if (error.reason._tag !== "StatusCodeError") { - throw new Error("expected StatusCodeError"); - } - expect(error.reason.response.status).toBe(404); - - expect(seenRequest).toBeDefined(); - expect(seenRequest?.url).toBe("https://api.supabase.com/v2/organizations/my-org/members"); - expect(seenRequest?.headers.authorization).toBe("Bearer test-token"); - }); + test("surfaces a 404 on a v2 operation as a distinguishable status error and wires the request identically to v1", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = yield* makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 404, { message: "Organization not found" }), + ); + }), + ), + ); + + const error = yield* client + .execute(operationDefinitions.v2ListOrganizationMembers, { slug: "my-org" }) + .pipe(Effect.flip); + + expect(HttpClientError.isHttpClientError(error)).toBe(true); + if (!HttpClientError.isHttpClientError(error)) { + throw new Error("expected HttpClientError"); + } + expect(error.reason._tag).toBe("StatusCodeError"); + if (error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(404); - test("decodes a nested v2GetProjectConfig payload through the unified execute path", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - data: { - type: "project_config", - id: "abcdefghijklmnopqrst", - attributes: { - database: { - major_version: 17, - ssl_enforced: true, - network_restrictions: { - entitlement: "disallowed", - status: "stored", - allowed_cidrs: [], + expect(seenRequest).toBeDefined(); + expect(seenRequest?.url).toBe("https://api.supabase.com/v2/organizations/my-org/members"); + expect(seenRequest?.headers.authorization).toBe("Bearer test-token"); + }), + )); + + test("decodes a nested v2GetProjectConfig payload through the unified execute path", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + data: { + type: "project_config", + id: "abcdefghijklmnopqrst", + attributes: { + database: { + major_version: 17, + ssl_enforced: true, + network_restrictions: { + entitlement: "disallowed", + status: "stored", + allowed_cidrs: [], + }, + postgres_settings: {}, }, - postgres_settings: {}, - }, - pooler: { - pool_mode: "transaction", - ignore_startup_parameters: "", - server_idle_timeout: 0, - server_lifetime: 0, - query_wait_timeout: 0, - reserve_pool_size: 0, - default_pool_size: 0, - max_client_conn: 0, - }, - auth: {}, - api: { - db_schema: "public", - db_extra_search_path: "", - max_rows: 1000, - db_pool_acquisition_timeout: 0, - db_pool: null, - }, - realtime: { - private_only: false, - max_concurrent_users: 0, - max_events_per_second: 0, - max_bytes_per_second: 0, - max_channels_per_client: 0, - max_joins_per_second: 0, - max_presence_events_per_second: 0, - max_payload_size_in_kb: 0, - presence_enabled: true, - suspend: false, - connection_pool: 0, - postgres_changes_pool: null, - }, - storage: { - file_size_limit: 0, - features: { - image_transformation: { enabled: true }, - s3_protocol: { enabled: true }, - purge_cache: { enabled: true }, - iceberg_catalog: { - enabled: false, - max_namespaces: 0, - max_tables: 0, - max_catalogs: 0, + pooler: { + pool_mode: "transaction", + ignore_startup_parameters: "", + server_idle_timeout: 0, + server_lifetime: 0, + query_wait_timeout: 0, + reserve_pool_size: 0, + default_pool_size: 0, + max_client_conn: 0, + }, + auth: {}, + api: { + db_schema: "public", + db_extra_search_path: "", + max_rows: 1000, + db_pool_acquisition_timeout: 0, + db_pool: null, + }, + realtime: { + private_only: false, + max_concurrent_users: 0, + max_events_per_second: 0, + max_bytes_per_second: 0, + max_channels_per_client: 0, + max_joins_per_second: 0, + max_presence_events_per_second: 0, + max_payload_size_in_kb: 0, + presence_enabled: true, + suspend: false, + connection_pool: 0, + postgres_changes_pool: null, + }, + storage: { + file_size_limit: 0, + features: { + image_transformation: { enabled: true }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: true }, + iceberg_catalog: { + enabled: false, + max_namespaces: 0, + max_tables: 0, + max_catalogs: 0, + }, + vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 }, }, - vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 }, + capabilities: { list_v2: true, iceberg_catalog: true }, + upstream_target: "main", + migration_version: "1", + database_pool_mode: "transaction", }, - capabilities: { list_v2: true, iceberg_catalog: true }, - upstream_target: "main", - migration_version: "1", - database_pool_mode: "transaction", }, }, - }, - }), + }), + ), ), ), - ), - ), - ); - - expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); - expect(result.data.attributes.database.major_version).toBe(17); - expect(result.data.attributes.storage.upstream_target).toBe("main"); - expect(result.data.attributes.api.db_pool).toBeNull(); - }); + ); + + expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); + expect(result.data.attributes.database.major_version).toBe(17); + expect(result.data.attributes.storage.upstream_target).toBe("main"); + expect(result.data.attributes.api.db_pool).toBeNull(); + }), + )); }); diff --git a/packages/api/src/internal/effect-client.ts b/packages/api/src/internal/effect-client.ts index 804c91b65e..deb13b4917 100644 --- a/packages/api/src/internal/effect-client.ts +++ b/packages/api/src/internal/effect-client.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import type * as EffectModule from "effect/Effect"; -import { type SupabaseApiClientShape, SupabaseApiClient } from "./client.ts"; +import { type SupabaseApiClientShape, type SupabaseApiError, SupabaseApiClient } from "./client.ts"; export type EffectClient<Operations extends object> = { readonly [Key in keyof Operations]: Operations[Key] extends ( @@ -27,7 +27,7 @@ export function makeEffectApiClient<Operations extends object>( ( value as ( ...args: ReadonlyArray<unknown> - ) => Effect.Effect<unknown, unknown, SupabaseApiClient> + ) => Effect.Effect<unknown, SupabaseApiError, SupabaseApiClient> )(...args).pipe(Effect.provideService(SupabaseApiClient, client)); } if (isRecord(value)) { diff --git a/packages/api/src/internal/promise-client.ts b/packages/api/src/internal/promise-client.ts index a6c44e2fe9..4cee6406cb 100644 --- a/packages/api/src/internal/promise-client.ts +++ b/packages/api/src/internal/promise-client.ts @@ -19,22 +19,29 @@ export function makePromiseClient<Operations extends object, Error>( runtime: ManagedRuntime.ManagedRuntime<never, Error>, operations: Operations, ): PromiseClient<Operations> { - const wrapOperation = (value: unknown): unknown => { - if (typeof value === "function") { - return (...args: ReadonlyArray<unknown>) => - runtime.runPromise( - (value as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown, never>)( - ...args, - ), - ); + const isOperation = ( + value: unknown, + ): value is (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, Error, never> => + typeof value === "function"; + + function wrapOperation<Args extends ReadonlyArray<unknown>, Output>( + value: (...args: Args) => Effect.Effect<Output, Error, never>, + ): (...args: Args) => Promise<Output>; + function wrapOperation<Value extends object>(value: Value): PromiseClient<Value>; + function wrapOperation(value: unknown): unknown { + if (isOperation(value)) { + return (...args: ReadonlyArray<unknown>) => runtime.runPromise(value(...args)); } if (isRecord(value)) { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, wrapOperation(entry)]), + Object.entries(value).map(([key, entry]) => [ + key, + isOperation(entry) || isRecord(entry) ? wrapOperation(entry) : entry, + ]), ); } return value; - }; + } - return wrapOperation(operations) as PromiseClient<Operations>; + return wrapOperation(operations); } diff --git a/packages/api/src/internal/promise-client.unit.test.ts b/packages/api/src/internal/promise-client.unit.test.ts index 0ab9046ef6..3f6c93c4cd 100644 --- a/packages/api/src/internal/promise-client.unit.test.ts +++ b/packages/api/src/internal/promise-client.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { Effect, Layer, ManagedRuntime, Option } from "effect"; +import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -51,15 +52,10 @@ function formDataTextValue(formData: FormData, key: string): string { return value; } -async function formDataFileTexts(formData: FormData, key: string): Promise<Array<string>> { +function formDataFileTexts(formData: FormData, key: string): Promise<Array<string>> { const values = formData.getAll(key); return Promise.all( - values.map(async (value) => { - if (typeof value === "string") { - return value; - } - return value.text(); - }), + values.map((value) => (typeof value === "string" ? Promise.resolve(value) : value.text())), ); } @@ -78,34 +74,63 @@ const config = { } as const; describe("makePromiseClient", () => { - test("preserves only the versioned facade namespace", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; - const runtime = ManagedRuntime.make( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); - - if (request.method === "POST" && request.url === "https://api.supabase.com/v1/projects") { - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - }), - ); - } + test("preserves only the versioned facade namespace", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + const runtime = ManagedRuntime.make( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); + + if ( + request.method === "POST" && + request.url === "https://api.supabase.com/v1/projects" + ) { + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }), + ); + } + + if ( + request.method === "GET" && + request.url === "https://api.supabase.com/v1/projects" + ) { + return Effect.succeed( + jsonResponse(request, 200, [ + { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, + }, + ]), + ); + } - if (request.method === "GET" && request.url === "https://api.supabase.com/v1/projects") { - return Effect.succeed( - jsonResponse(request, 200, [ - { + return Effect.succeed( + jsonResponse(request, 200, { id: "project-id", ref: "abcdefghijklmnopqrst", organization_id: "org-id", @@ -120,134 +145,131 @@ describe("makePromiseClient", () => { postgres_engine: "17", release_channel: "ga", }, - }, - ]), + }), + ); + }), + ); + + try { + const effectClient = yield* Effect.promise(() => + runtime.runPromise(makeApiClient(config)), ); - } + const client = makePromiseClient(runtime, effectClient); + + expect("createAProject" in client).toBe(false); + expect("getProject" in client).toBe(false); + expect("listAllProjects" in client).toBe(false); + expect(typeof client.v1.createAProject).toBe("function"); + expect(typeof client.v1.getProject).toBe("function"); + expect(typeof client.v1.listAllProjects).toBe("function"); + + const created = yield* Effect.promise(() => + client.v1.createAProject({ + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ); + const project = yield* Effect.promise(() => + client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }), + ); + const projects = yield* Effect.promise(() => client.v1.listAllProjects()); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", + expect(created.ref).toBe("abcdefghijklmnopqrst"); + expect(project.database.host).toBe("db.supabase.internal"); + expect(projects).toHaveLength(1); + expect(seenRequests).toEqual([ + { + method: "POST", + url: "https://api.supabase.com/v1/projects", }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects", + }, + ]); + } finally { + yield* Effect.promise(() => runtime.dispose()); + } + }), + )); + + test("serializes generated multipart methods through the promise facade", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const runtime = ManagedRuntime.make( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 201, { + id: "function-id", + slug: "demo", + name: "Demo Function", + status: "ACTIVE", + version: 1, + created_at: 1_710_000_000, + updated_at: 1_710_000_001, + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + }), + ); }), ); - }), - ); - - try { - const effectClient = await runtime.runPromise(makeApiClient(config)); - const client = makePromiseClient(runtime, effectClient); - - expect("createAProject" in client).toBe(false); - expect("getProject" in client).toBe(false); - expect("listAllProjects" in client).toBe(false); - expect(typeof client.v1.createAProject).toBe("function"); - expect(typeof client.v1.getProject).toBe("function"); - expect(typeof client.v1.listAllProjects).toBe("function"); - - const created = await client.v1.createAProject({ - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }); - const project = await client.v1.getProject({ - ref: "abcdefghijklmnopqrst", - }); - const projects = await client.v1.listAllProjects(); - - expect(created.ref).toBe("abcdefghijklmnopqrst"); - expect(project.database.host).toBe("db.supabase.internal"); - expect(projects).toHaveLength(1); - expect(seenRequests).toEqual([ - { - method: "POST", - url: "https://api.supabase.com/v1/projects", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects", - }, - ]); - } finally { - await runtime.dispose(); - } - }); - - test("serializes generated multipart methods through the promise facade", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const runtime = ManagedRuntime.make( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed( - jsonResponse(request, 201, { - id: "function-id", - slug: "demo", - name: "Demo Function", - status: "ACTIVE", - version: 1, - created_at: 1_710_000_000, - updated_at: 1_710_000_001, - verify_jwt: true, + + try { + const effectClient = yield* Effect.promise(() => + runtime.runPromise(makeApiClient(config)), + ); + const client = makePromiseClient(runtime, effectClient); + + const metadata = { entrypoint_path: "functions/demo/index.ts", import_map_path: "functions/demo/deno.json", - }), - ); + verify_jwt: true, + name: "demo", + } as const; + + const result = yield* Effect.promise(() => + client.v1.deployAFunction({ + ref: "abcdefghijklmnopqrst", + slug: "demo", + bundleOnly: true, + body: { + metadata, + file: [new Uint8Array([1, 2, 3]), new Blob(["deno.json"])], + }, + }), + ); + + expect(result.slug).toBe("demo"); + expect(new URL(seenRequest!.url).pathname).toBe( + "/v1/projects/abcdefghijklmnopqrst/functions/deploy", + ); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); + expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); + + const formData = requestFormData(seenRequest!); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + formDataTextValue(formData, "metadata"), + ), + ).toEqual(metadata); + expect(yield* Effect.promise(() => formDataFileTexts(formData, "file"))).toEqual([ + "\u0001\u0002\u0003", + "deno.json", + ]); + } finally { + yield* Effect.promise(() => runtime.dispose()); + } }), - ); - - try { - const effectClient = await runtime.runPromise(makeApiClient(config)); - const client = makePromiseClient(runtime, effectClient); - - const metadata = { - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - verify_jwt: true, - name: "demo", - } as const; - - const result = await client.v1.deployAFunction({ - ref: "abcdefghijklmnopqrst", - slug: "demo", - bundleOnly: true, - body: { - metadata, - file: [new Uint8Array([1, 2, 3]), new Blob(["deno.json"])], - }, - }); - - expect(result.slug).toBe("demo"); - expect(new URL(seenRequest!.url).pathname).toBe( - "/v1/projects/abcdefghijklmnopqrst/functions/deploy", - ); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); - expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); - - const formData = requestFormData(seenRequest!); - expect(JSON.parse(formDataTextValue(formData, "metadata"))).toEqual(metadata); - expect(await formDataFileTexts(formData, "file")).toEqual([ - "\u0001\u0002\u0003", - "deno.json", - ]); - } finally { - await runtime.dispose(); - } - }); + )); }); diff --git a/packages/api/src/node.ts b/packages/api/src/node.ts index 7d81405fec..2f1bc50553 100644 --- a/packages/api/src/node.ts +++ b/packages/api/src/node.ts @@ -18,13 +18,14 @@ const nodeHttpClientLayer = NodeHttpClient.layerUndiciNoDispatcher.pipe( Layer.provide(nodeDispatcherLayer), ); -export async function createApiClient( +export function createApiClient( config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions, ): Promise<PromiseSupabaseApiClient> { const runtime = ManagedRuntime.make(nodeHttpClientLayer); - const effectClient = await runtime.runPromise(makeApiClient(config, options)); - return makePromiseClient(runtime, effectClient); + return runtime + .runPromise(makeApiClient(config, options)) + .then((effectClient) => makePromiseClient(runtime, effectClient)); } export type PromiseSupabaseApiClient = PromiseClient<ApiClient>; diff --git a/packages/cli-test-helpers/package.json b/packages/cli-test-helpers/package.json index a824dd7683..6548974391 100644 --- a/packages/cli-test-helpers/package.json +++ b/packages/cli-test-helpers/package.json @@ -11,6 +11,10 @@ "check:all": "nx run-many -t types:check lint:check fmt:check knip:check --projects=$npm_package_name", "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, + "dependencies": { + "@effect/platform-bun": "catalog:", + "effect": "catalog:" + }, "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", diff --git a/packages/cli-test-helpers/src/harness.ts b/packages/cli-test-helpers/src/harness.ts index ede3a840f0..6c16a10882 100644 --- a/packages/cli-test-helpers/src/harness.ts +++ b/packages/cli-test-helpers/src/harness.ts @@ -1,7 +1,48 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; import { tmpdir, platform as osPlatform } from "node:os"; import { randomUUID } from "node:crypto"; +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Path } from "effect"; +import type { PlatformError } from "effect/PlatformError"; + +const runBunSync = <A, E extends Error>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, +): A => Effect.runSync(effect.pipe(Effect.orDie, Effect.provide(BunServices.layer))); + +const runBunPromise = <A, E extends Error>( + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, +): Promise<A> => Effect.runPromise(effect.pipe(Effect.orDie, Effect.provide(BunServices.layer))); + +const join = (...parts: ReadonlyArray<string>): string => + runBunSync( + Effect.gen(function* () { + const path = yield* Path.Path; + return path.join(...parts); + }), + ); + +const exists = (path: string): Effect.Effect<boolean, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }); + +const mkdtemp = (prefix: string): Effect.Effect<string, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectory({ directory: tmpdir(), prefix }); + }); + +const rm = ( + path: string, + options?: { readonly recursive?: boolean; readonly force?: boolean }, +): Effect.Effect<void, PlatformError, FileSystem.FileSystem> => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.remove(path, { + recursive: options?.recursive ?? false, + force: options?.force ?? false, + }); + }); export type CLITarget = "ts-legacy" | "ts-next"; @@ -17,6 +58,8 @@ export interface HarnessOptions { apiUrl: string; /** Access token injected as SUPABASE_ACCESS_TOKEN */ accessToken: string; + /** Monorepo root containing apps/cli/dist. Defaults to this workspace root. */ + workspaceRoot?: string; /** Working directory for the subprocess. Defaults to a fresh temp dir. */ cwd?: string; /** Set as SUPABASE_PROJECT_ID in the subprocess env. Storage commands read @@ -37,19 +80,38 @@ export interface CLIHarness { /** A temporary directory that is removed when disposed. */ export interface TempDir { readonly path: string; - [Symbol.dispose](): void; + [Symbol.asyncDispose](): Promise<void>; +} + +class MissingCliBuildError extends Data.TaggedError("MissingCliBuildError")<{ + readonly shimPath: string; + readonly binaryPath: string; + readonly message: string; +}> { + constructor(shimPath: string, binaryPath: string) { + super({ + shimPath, + binaryPath, + message: + `Missing CLI build artifacts. Run \`pnpm --filter supabase build\` before running e2e tests.\n` + + ` expected shim: ${shimPath}\n` + + ` expected binary: ${binaryPath}`, + }); + } } /** Create a unique temporary directory under os.tmpdir() for use as a CLI * working directory. Dispose it after the test to clean up. */ -export function makeTempDir(prefix = "cli-e2e-"): TempDir { - const path = mkdtempSync(join(tmpdir(), prefix)); - return { - path, - [Symbol.dispose]() { - rmSync(path, { recursive: true, force: true }); - }, - }; +export function makeTempDir(prefix = "cli-e2e-"): Promise<TempDir> { + return runBunPromise( + Effect.gen(function* () { + const path = yield* mkdtemp(prefix); + return { + path, + [Symbol.asyncDispose]: () => runBunPromise(rm(path, { recursive: true, force: true })), + } satisfies TempDir; + }), + ); } // Resolve the monorepo root from this file's location: @@ -57,7 +119,6 @@ export function makeTempDir(prefix = "cli-e2e-"): TempDir { const WORKSPACE_ROOT = new URL("../../../", import.meta.url).pathname.replace(/\/$/, ""); const BINARY_EXT = osPlatform() === "win32" ? ".exe" : ""; -const TS_CLI_SHIM = join(WORKSPACE_ROOT, "apps/cli/dist/supabase.js"); // E2E subprocesses should only enter agent output mode when a test explicitly // opts in via `opts.env`. Keep this list aligned with @vercel/detect-agent env @@ -94,51 +155,51 @@ export function createSubprocessBaseEnv( return env; } -function tsCliBinary(shell: "next" | "legacy"): string { - return join(WORKSPACE_ROOT, `apps/cli/dist/supabase-${shell}${BINARY_EXT}`); +function tsCliShim(workspaceRoot: string): string { + return join(workspaceRoot, "apps/cli/dist/supabase.js"); } -function assertTsCliBuilt(binaryPath: string): void { - if (!existsSync(TS_CLI_SHIM) || !existsSync(binaryPath)) { - throw new Error( - `Missing CLI build artifacts. Run \`pnpm --filter supabase build\` before running e2e tests.\n` + - ` expected shim: ${TS_CLI_SHIM}\n` + - ` expected binary: ${binaryPath}`, - ); - } +function tsCliBinary(workspaceRoot: string, shell: "next" | "legacy"): string { + return join(workspaceRoot, `apps/cli/dist/supabase-${shell}${BINARY_EXT}`); } +const assertTsCliBuilt = (shimPath: string, binaryPath: string) => + Effect.gen(function* () { + const shimExists = yield* exists(shimPath); + const binaryExists = yield* exists(binaryPath); + if (!shimExists || !binaryExists) { + return yield* new MissingCliBuildError(shimPath, binaryPath); + } + }); + interface BuiltCommand { cmd: string[]; binaryOverride?: string; } -function buildCommand(target: CLITarget): BuiltCommand { - switch (target) { - case "ts-legacy": { - const binaryPath = tsCliBinary("legacy"); - assertTsCliBuilt(binaryPath); - return { cmd: ["node", TS_CLI_SHIM], binaryOverride: binaryPath }; - } - case "ts-next": { - const binaryPath = tsCliBinary("next"); - assertTsCliBuilt(binaryPath); - return { cmd: ["node", TS_CLI_SHIM], binaryOverride: binaryPath }; - } - } -} +const buildCommand = (target: CLITarget, workspaceRoot: string) => + Effect.gen(function* () { + const shell = target === "ts-legacy" ? "legacy" : "next"; + const shimPath = tsCliShim(workspaceRoot); + const binaryPath = tsCliBinary(workspaceRoot, shell); + yield* assertTsCliBuilt(shimPath, binaryPath); + return { cmd: ["node", shimPath], binaryOverride: binaryPath } satisfies BuiltCommand; + }); export function createHarness(target: CLITarget, options: HarnessOptions): CLIHarness { return { target, options }; } +// oxlint-disable-next-line effecttsgo/async-function -- Promise facade intentionally consumed by non-Effect e2e tests. export async function exec( harness: CLIHarness, args: string[], opts?: { env?: Record<string, string> }, ): Promise<CLIResult> { const start = performance.now(); - const built = buildCommand(harness.target); + const built = await runBunPromise( + buildCommand(harness.target, harness.options.workspaceRoot ?? WORKSPACE_ROOT), + ); const env: Record<string, string> = { ...createSubprocessBaseEnv(), @@ -168,16 +229,22 @@ export async function exec( // - ts-next reads SUPABASE_API_URL directly, so it doesn't need a profile file. let profilePath: string | undefined; if (harness.target === "ts-legacy") { - profilePath = join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`); + const nextProfilePath = join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`); + profilePath = nextProfilePath; const url = harness.options.apiUrl; - writeFileSync( - profilePath, - [ - `name: test`, - `api_url: "${url}"`, - `dashboard_url: "${url}"`, - `project_host: ${harness.options.projectHost ?? "localhost"}`, - ].join("\n"), + await runBunPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString( + nextProfilePath, + [ + `name: test`, + `api_url: "${url}"`, + `dashboard_url: "${url}"`, + `project_host: ${harness.options.projectHost ?? "localhost"}`, + ].join("\n"), + ); + }), ); env["SUPABASE_PROFILE"] = profilePath; } else { @@ -201,7 +268,7 @@ export async function exec( const exitCode = await proc.exited; const durationMs = performance.now() - start; - if (profilePath) rmSync(profilePath, { force: true }); + if (profilePath) await runBunPromise(rm(profilePath, { force: true })); return { stdout, stderr, exitCode, durationMs }; } diff --git a/packages/cli-test-helpers/src/harness.unit.test.ts b/packages/cli-test-helpers/src/harness.unit.test.ts index 94612efe63..3f099d4150 100644 --- a/packages/cli-test-helpers/src/harness.unit.test.ts +++ b/packages/cli-test-helpers/src/harness.unit.test.ts @@ -1,5 +1,8 @@ +import { tmpdir } from "node:os"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, FileSystem, Path } from "effect"; import { describe, expect, it } from "vitest"; -import { createSubprocessBaseEnv } from "./harness.ts"; +import { createHarness, createSubprocessBaseEnv, exec, makeTempDir } from "./harness.ts"; describe("createSubprocessBaseEnv", () => { it("removes inherited agent-detection environment variables", () => { @@ -23,3 +26,53 @@ describe("createSubprocessBaseEnv", () => { ).toEqual({ PATH: "/usr/bin" }); }); }); + +describe("makeTempDir", () => { + it("creates and disposes an isolated working directory", () => + makeTempDir("cli-helper-").then((temp) => { + const inspect = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { + exists: yield* fs.exists(temp.path), + parent: path.dirname(temp.path), + name: path.basename(temp.path), + }; + }).pipe(Effect.provide(BunServices.layer), Effect.runPromise); + return inspect.then((before) => { + expect(before.parent).toBe(tmpdir()); + expect(before.name).toMatch(/^cli-helper-/u); + expect(before.exists).toBe(true); + return temp[Symbol.asyncDispose]().then(() => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(temp.path); + }) + .pipe(Effect.provide(BunServices.layer), Effect.runPromise) + .then((exists) => { + expect(exists).toBe(false); + }), + ); + }); + })); +}); + +describe("exec", () => { + it("reports missing CLI build artifacts", () => + makeTempDir("cli-helper-workspace-").then((workspace) => { + const result = exec( + createHarness("ts-next", { + apiUrl: "http://127.0.0.1", + accessToken: "token", + workspaceRoot: workspace.path, + }), + [], + ); + + const assertion = expect(result).rejects.toMatchObject({ + binaryPath: `${workspace.path}/apps/cli/dist/supabase-next`, + message: expect.stringContaining(`${workspace.path}/apps/cli/dist/supabase.js`), + }); + return assertion.finally(() => workspace[Symbol.asyncDispose]()); + })); +}); diff --git a/packages/config/docs/project-config-loading.md b/packages/config/docs/project-config-loading.md index 0cf5461bef..790d3bd67f 100644 --- a/packages/config/docs/project-config-loading.md +++ b/packages/config/docs/project-config-loading.md @@ -81,11 +81,16 @@ participate in runtime config semantics. 1. `supabase/.env` 2. `supabase/.env.local` -3. `process.env` passed in as `baseEnv` +3. An explicit `baseEnv` map, when supplied + +The core loader does not read process-global environment state when `baseEnv` is +omitted. Node and Bun convenience adapters opt into ambient values by passing +`process.env` explicitly; direct Effect callers must do the same when they need +that behavior. The resulting precedence is: -- `process.env` wins over `.env.local` +- `baseEnv` wins over `.env.local` - `.env.local` wins over `.env` - `.env` provides the lowest-priority project values @@ -96,7 +101,8 @@ The loader returns a `ProjectEnvironment` object containing: - `loadedPaths` - `sources`: per-key provenance (`.env`, `.env.local`, or `ambient`) -The `ambient` source label just means the value came from `process.env`. +The `ambient` source label means the value came from the explicit `baseEnv` map +(the Node/Bun adapters populate that map from `process.env`). ## Raw Config Loading diff --git a/packages/config/package.json b/packages/config/package.json index 2e414c83cc..6ce44c7ae2 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -24,6 +24,7 @@ "smol-toml": "^1.8.0" }, "devDependencies": { + "@effect/vitest": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@vitest/coverage-istanbul": "catalog:", diff --git a/packages/config/scripts/build.ts b/packages/config/scripts/build.ts index 27c226decf..ecb2983ced 100644 --- a/packages/config/scripts/build.ts +++ b/packages/config/scripts/build.ts @@ -1,25 +1,47 @@ -import { mkdir } from "node:fs/promises"; +import { BunServices } from "@effect/platform-bun"; +import { Data, Effect, FileSystem, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { toProjectConfigJsonSchema } from "../src/base.ts"; -const json = toProjectConfigJsonSchema(); -const schema = `${JSON.stringify(json, null, 2)}\n`; +class BuildError extends Data.TaggedError("BuildError")<{ + readonly cause: unknown; +}> {} -const formatter = Bun.spawn(["bun", "x", "oxfmt", "--stdin-filepath=./dist/schema.json"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", -}); -formatter.stdin.write(schema); -formatter.stdin.end(); +const program = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const json = toProjectConfigJsonSchema(); + const schema = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(json); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const formatted = yield* Effect.scoped( + Effect.gen(function* () { + const formatter = yield* spawner.spawn( + ChildProcess.make("bun", ["x", "oxfmt", "--stdin-filepath=./dist/schema.json"], { + extendEnv: true, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }), + ); + yield* Stream.run(Stream.make(new TextEncoder().encode(`${schema}\n`)), formatter.stdin); + const [exitCode, output, stderr] = yield* Effect.all( + [ + formatter.exitCode, + Stream.mkString(Stream.decodeText(formatter.stdout)), + Stream.mkString(Stream.decodeText(formatter.stderr)), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) { + return yield* new BuildError({ + cause: `oxfmt failed with exit code ${exitCode}: ${stderr.trim()}`, + }); + } + return output; + }), + ).pipe(Effect.mapError((cause) => new BuildError({ cause }))); -const [exitCode, formatted, stderr] = await Promise.all([ - formatter.exited, - new Response(formatter.stdout).text(), - new Response(formatter.stderr).text(), -]); -if (exitCode !== 0) { - throw new Error(`oxfmt failed with exit code ${exitCode}: ${stderr.trim()}`); -} + yield* fileSystem.makeDirectory("./dist", { recursive: true }); + yield* fileSystem.writeFileString("./dist/schema.json", formatted); +}); -await mkdir("./dist", { recursive: true }); -await Bun.write("./dist/schema.json", formatted); +await Effect.runPromise(program.pipe(Effect.provide(BunServices.layer))); diff --git a/packages/config/src/analytics.ts b/packages/config/src/analytics.ts index fd3cf12128..29f61f39f7 100644 --- a/packages/config/src/analytics.ts +++ b/packages/config/src/analytics.ts @@ -22,7 +22,7 @@ export const analytics = Schema.Struct({ tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnabled))), - port: Schema.Number.annotate({ + port: Schema.Finite.annotate({ default: defaultPort, description: "Port to the local Logflare service.", tags, @@ -40,7 +40,7 @@ export const analytics = Schema.Struct({ links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultBackend))), vector_port: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Port to the local syslog ingest service.", tags, }), diff --git a/packages/config/src/api.ts b/packages/config/src/api.ts index b171756a7b..cb0061e9b1 100644 --- a/packages/config/src/api.ts +++ b/packages/config/src/api.ts @@ -24,7 +24,7 @@ export const api = Schema.Struct({ tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnabled))), - port: Schema.Number.annotate({ + port: Schema.Finite.annotate({ default: defaultPort, description: "Port to use for the API URL.", tags, @@ -49,7 +49,7 @@ export const api = Schema.Struct({ ) .annotate({ default: defaultExtraSearchPath }) .pipe(Schema.withDecodingDefaultKey(Effect.succeed([...defaultExtraSearchPath]))), - max_rows: Schema.Number.annotate({ + max_rows: Schema.Finite.annotate({ default: defaultMaxRows, description: "The maximum number of rows returned from a view, table, or stored procedure. Limits payload size for accidental or malicious requests.", diff --git a/packages/config/src/auth/email.ts b/packages/config/src/auth/email.ts index 4b17fb0a6f..ace13d2eac 100644 --- a/packages/config/src/auth/email.ts +++ b/packages/config/src/auth/email.ts @@ -114,13 +114,13 @@ export const email = Schema.Struct({ tags, links: [links.auth], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxFrequency))), - otp_length: Schema.Number.annotate({ + otp_length: Schema.Finite.annotate({ default: defaultOtpLength, description: "Number of characters used in the email OTP.", tags, links: [links.auth], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultOtpLength))), - otp_expiry: Schema.Number.annotate({ + otp_expiry: Schema.Finite.annotate({ default: defaultOtpExpiry, description: "Number of seconds before the email OTP expires.", tags, @@ -138,7 +138,7 @@ export const email = Schema.Struct({ }), ), port: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Port number of the SMTP server.", }), ), diff --git a/packages/config/src/auth/index.ts b/packages/config/src/auth/index.ts index 2730a7d01c..6c3cff2625 100644 --- a/packages/config/src/auth/index.ts +++ b/packages/config/src/auth/index.ts @@ -66,7 +66,7 @@ export const auth = Schema.Struct({ links: [links.auth], }) .pipe(Schema.withDecodingDefaultKey(Effect.succeed([...defaultAdditionalRedirectUrls]))), - jwt_expiry: Schema.Number.annotate({ + jwt_expiry: Schema.Finite.annotate({ default: defaultJwtExpiry, description: "How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week).", @@ -93,7 +93,7 @@ export const auth = Schema.Struct({ tags, links: [links.auth], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnableRefreshTokenRotation))), - refresh_token_reuse_interval: Schema.Number.annotate({ + refresh_token_reuse_interval: Schema.Finite.annotate({ default: defaultRefreshTokenReuseInterval, description: "Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.", @@ -118,7 +118,7 @@ export const auth = Schema.Struct({ tags, links: [links.auth], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnableAnonymousSignIns))), - minimum_password_length: Schema.Number.annotate({ + minimum_password_length: Schema.Finite.annotate({ default: defaultMinimumPasswordLength, description: "Passwords shorter than this value will be rejected as weak.", tags, diff --git a/packages/config/src/auth/mfa.ts b/packages/config/src/auth/mfa.ts index 8a638a092f..c364059508 100644 --- a/packages/config/src/auth/mfa.ts +++ b/packages/config/src/auth/mfa.ts @@ -60,7 +60,7 @@ export const mfa = Schema.Struct({ tags, links: [links.phone], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPhoneVerifyEnabled))), - otp_length: Schema.Number.annotate({ + otp_length: Schema.Finite.annotate({ default: defaultPhoneOtpLength, description: "The length of the OTP code.", tags, @@ -93,7 +93,7 @@ export const mfa = Schema.Struct({ links: [links.mfa], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultWebAuthnVerifyEnabled))), }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({ ...defaultWebAuthn }))), - max_enrolled_factors: Schema.Number.annotate({ + max_enrolled_factors: Schema.Finite.annotate({ default: defaultMaxEnrolledFactors, description: "The maximum number of MFA factors a user can enroll in.", tags, diff --git a/packages/config/src/auth/rate_limit.ts b/packages/config/src/auth/rate_limit.ts index e5ab60de50..e1a4ae1a8b 100644 --- a/packages/config/src/auth/rate_limit.ts +++ b/packages/config/src/auth/rate_limit.ts @@ -19,45 +19,45 @@ const defaultTokenVerifications = 30; const defaultWeb3 = 30; export const rate_limit = Schema.Struct({ - email_sent: Schema.Number.annotate({ + email_sent: Schema.Finite.annotate({ default: defaultEmailSent, description: "Number of emails that can be sent per hour.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEmailSent))), - sms_sent: Schema.Number.annotate({ + sms_sent: Schema.Finite.annotate({ default: defaultSmsSent, description: "Number of SMS messages that can be sent per hour.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultSmsSent))), - anonymous_users: Schema.Number.annotate({ + anonymous_users: Schema.Finite.annotate({ default: defaultAnonymousUsers, description: "Number of anonymous sign-ins that can be made per hour per IP address.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultAnonymousUsers))), - token_refresh: Schema.Number.annotate({ + token_refresh: Schema.Finite.annotate({ default: defaultTokenRefresh, description: "Number of sessions that can be refreshed in a 5 minute interval per IP address.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultTokenRefresh))), - sign_in_sign_ups: Schema.Number.annotate({ + sign_in_sign_ups: Schema.Finite.annotate({ default: defaultSignInSignUps, description: "Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultSignInSignUps))), - token_verifications: Schema.Number.annotate({ + token_verifications: Schema.Finite.annotate({ default: defaultTokenVerifications, description: "Number of OTP or magic link verifications that can be made in a 5 minute interval per IP address.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultTokenVerifications))), - web3: Schema.Number.annotate({ + web3: Schema.Finite.annotate({ default: defaultWeb3, description: "Number of Web3 logins that can be made in a 5 minute interval per IP address.", tags, diff --git a/packages/config/src/bun.ts b/packages/config/src/bun.ts index 2e855bbadd..a9bffac3ca 100644 --- a/packages/config/src/bun.ts +++ b/packages/config/src/bun.ts @@ -1,5 +1,5 @@ import { BunServices } from "@effect/platform-bun"; -import { Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import type { LoadedProjectConfig, LoadProjectConfigOptions, @@ -22,7 +22,7 @@ function makeRuntime() { ); } -export async function loadProjectConfig( +export function loadProjectConfig( cwd: string, options?: LoadProjectConfigOptions, ): Promise<LoadedProjectConfig | null> { @@ -30,22 +30,22 @@ export async function loadProjectConfig( return runtime.runPromise(ProjectConfigStore.use((store) => store.load(cwd, options))); } -export async function findProjectRootFor(cwd: string): Promise<string | null> { +export function findProjectRootFor(cwd: string): Promise<string | null> { const runtime = makeRuntime(); return runtime.runPromise(findProjectRoot(cwd)); } -export async function findProjectPathsFor(cwd: string): Promise<ProjectPaths | null> { +export function findProjectPathsFor(cwd: string): Promise<ProjectPaths | null> { const runtime = makeRuntime(); return runtime.runPromise(findProjectPaths(cwd)); } -export async function loadProjectConfigFile(path: string): Promise<LoadedProjectConfig> { +export function loadProjectConfigFile(path: string): Promise<LoadedProjectConfig> { const runtime = makeRuntime(); return runtime.runPromise(ProjectConfigStore.use((store) => store.loadFile(path))); } -export async function loadProjectEnvironmentFor( +export function loadProjectEnvironmentFor( options: LoadProjectEnvironmentOptions, ): Promise<ProjectEnvironment | null> { const runtime = makeRuntime(); @@ -54,14 +54,20 @@ export async function loadProjectEnvironmentFor( ); } -export async function saveProjectConfig( - options: SaveProjectConfigOptions, -): Promise<LoadedProjectConfig> { +export function saveProjectConfig(options: SaveProjectConfigOptions): Promise<LoadedProjectConfig> { const runtime = makeRuntime(); return runtime.runPromise(ProjectConfigStore.use((store) => store.save(options))); } -export async function loadFunctionsManifest(cwd: string): Promise<FunctionsManifest> { +export function loadFunctionsManifest(cwd: string): Promise<FunctionsManifest> { const runtime = makeRuntime(); - return runtime.runPromise(inferFunctionsManifest({ cwd })); + return runtime.runPromise( + Effect.gen(function* () { + const projectEnv = yield* loadProjectEnvironment({ cwd, baseEnv: process.env }); + return yield* inferFunctionsManifest({ + cwd, + ...(projectEnv === null ? {} : { projectEnv }), + }); + }), + ); } diff --git a/packages/config/src/db.ts b/packages/config/src/db.ts index 28e1fdabe6..952d8014e9 100644 --- a/packages/config/src/db.ts +++ b/packages/config/src/db.ts @@ -40,18 +40,18 @@ const settings = Schema.Struct({ effective_cache_size: Schema.optionalKey(Schema.String), logical_decoding_work_mem: Schema.optionalKey(Schema.String), maintenance_work_mem: Schema.optionalKey(Schema.String), - max_connections: Schema.optionalKey(Schema.Number), - max_locks_per_transaction: Schema.optionalKey(Schema.Number), - max_parallel_maintenance_workers: Schema.optionalKey(Schema.Number), - max_parallel_workers: Schema.optionalKey(Schema.Number), - max_parallel_workers_per_gather: Schema.optionalKey(Schema.Number), - max_replication_slots: Schema.optionalKey(Schema.Number), + max_connections: Schema.optionalKey(Schema.Finite), + max_locks_per_transaction: Schema.optionalKey(Schema.Finite), + max_parallel_maintenance_workers: Schema.optionalKey(Schema.Finite), + max_parallel_workers: Schema.optionalKey(Schema.Finite), + max_parallel_workers_per_gather: Schema.optionalKey(Schema.Finite), + max_replication_slots: Schema.optionalKey(Schema.Finite), max_slot_wal_keep_size: Schema.optionalKey(Schema.String), max_standby_archive_delay: Schema.optionalKey(Schema.String), max_standby_streaming_delay: Schema.optionalKey(Schema.String), max_wal_size: Schema.optionalKey(Schema.String), - max_wal_senders: Schema.optionalKey(Schema.Number), - max_worker_processes: Schema.optionalKey(Schema.Number), + max_wal_senders: Schema.optionalKey(Schema.Finite), + max_worker_processes: Schema.optionalKey(Schema.Finite), session_replication_role: Schema.optionalKey( stringEnum(["origin", "replica", "local"], { description: "Session replication role.", @@ -68,13 +68,13 @@ const settings = Schema.Struct({ }).pipe(Schema.withDecodingDefaultKey(Effect.succeed({}))); export const db = Schema.Struct({ - port: Schema.Number.annotate({ + port: Schema.Finite.annotate({ default: defaultPort, description: "Port to use for the local database URL.", tags, links: [links.postgres], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPort))), - shadow_port: Schema.Number.annotate({ + shadow_port: Schema.Finite.annotate({ default: defaultShadowPort, description: "Port used by db diff command to initialize the shadow database.", tags, @@ -85,7 +85,7 @@ export const db = Schema.Struct({ "Maximum amount of time to wait for health check when starting the local database.", tags, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultHealthTimeout))), - major_version: Schema.Number.annotate({ + major_version: Schema.Finite.annotate({ default: defaultMajorVersion, description: "The database major version to use. This has to be the same as your remote database's.", @@ -99,7 +99,7 @@ export const db = Schema.Struct({ tags, links: [links.pgbouncer()], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPoolerEnabled))), - port: Schema.Number.annotate({ + port: Schema.Finite.annotate({ default: defaultPoolerPort, description: "Port to use for the local connection pooler.", tags, @@ -111,13 +111,13 @@ export const db = Schema.Struct({ tags, links: [links.pgbouncer("pool_mode")], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPoolMode))), - default_pool_size: Schema.Number.annotate({ + default_pool_size: Schema.Finite.annotate({ default: defaultPoolSize, description: "How many server connections to allow per user/database pair.", tags, links: [links.pgbouncer("default_pool_size")], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPoolSize))), - max_client_conn: Schema.Number.annotate({ + max_client_conn: Schema.Finite.annotate({ default: defaultMaxClientConn, description: "Maximum number of client connections allowed.", tags, diff --git a/packages/config/src/edge_runtime.ts b/packages/config/src/edge_runtime.ts index e5a78e0b5a..5ca240439d 100644 --- a/packages/config/src/edge_runtime.ts +++ b/packages/config/src/edge_runtime.ts @@ -20,12 +20,12 @@ export const edge_runtime = Schema.Struct({ description: "Configure the supported request policy.", tags, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPolicy))), - inspector_port: Schema.Number.annotate({ + inspector_port: Schema.Finite.annotate({ default: defaultInspectorPort, description: "Port to run the Edge Functions inspector on.", tags, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultInspectorPort))), - deno_version: Schema.Number.annotate({ + deno_version: Schema.Finite.annotate({ default: defaultDenoVersion, description: "The Deno major version to use.", tags, diff --git a/packages/config/src/functions-manifest.ts b/packages/config/src/functions-manifest.ts index 02896aa0aa..239d0c1ea3 100644 --- a/packages/config/src/functions-manifest.ts +++ b/packages/config/src/functions-manifest.ts @@ -2,6 +2,7 @@ import { Effect, FileSystem, Path, Schema } from "effect"; import { ProjectConfigSchema, type ProjectConfig } from "./base.ts"; import { loadProjectConfig } from "./io.ts"; import { findProjectPaths } from "./paths.ts"; +import type { ProjectEnvironment } from "./project.ts"; const functionSlugPattern = /^[a-zA-Z0-9_-]+$/; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); @@ -25,6 +26,7 @@ export type FunctionsManifest = Readonly<Record<string, ResolvedFunctionConfig>> interface InferFunctionsManifestOptions { readonly cwd: string; readonly config?: ProjectConfig; + readonly projectEnv?: ProjectEnvironment; /** Forwarded to {@link findProjectPaths}'s own `search` option — see its doc comment. */ readonly search?: boolean; } @@ -84,9 +86,10 @@ export const inferFunctionsManifest = Effect.fnUntraced(function* ( const projectRoot = projectPaths?.projectRoot ?? options.cwd; const config = options.config ?? - (yield* loadProjectConfig(options.cwd).pipe( - Effect.map((loaded) => loaded?.config ?? emptyConfig), - )); + (yield* loadProjectConfig(options.cwd, { + ...(options.projectEnv === undefined ? {} : { projectEnv: options.projectEnv }), + ...(options.search === undefined ? {} : { search: options.search }), + }).pipe(Effect.map((loaded) => loaded?.config ?? emptyConfig))); const functionsDir = path.join(projectRoot, "supabase", edgeFunctionsDirectoryName); const filesystemFunctions: Record<string, ResolvedFunctionConfig> = {}; diff --git a/packages/config/src/functions-manifest.unit.test.ts b/packages/config/src/functions-manifest.unit.test.ts index 085d160768..d9f40ec876 100644 --- a/packages/config/src/functions-manifest.unit.test.ts +++ b/packages/config/src/functions-manifest.unit.test.ts @@ -1,219 +1,260 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, FileSystem, Path, Schema } from "effect"; +import { Data, Effect, FileSystem, Path, PlatformError, Schema } from "effect"; import { ProjectConfigSchema } from "./base.ts"; import { inferFunctionsManifest } from "./functions-manifest.ts"; +import { loadProjectEnvironment } from "./project.ts"; const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-functions-manifest-")); -} +const withTempProject = <A, E>( + run: (cwd: string) => Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, +): Effect.Effect<A, E | PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "supabase-functions-manifest-" }); + return yield* run(cwd); + }), + ); -function runConfigEffect<A, E>( - effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, -): Promise<A> { - return Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); -} +class FunctionsManifestTestError extends Data.TaggedError("FunctionsManifestTestError")<{ + readonly cause: unknown; +}> {} + +const platform = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => + effect.pipe( + Effect.provide(BunServices.layer), + Effect.mapError((cause) => new FunctionsManifestTestError({ cause })), + ); describe("functions manifest", () => { - test("detects default functions from the filesystem", async () => { - const cwd = makeTempProject(); - - try { - const functionDir = join(cwd, "supabase", "functions", "hello-world"); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), "Deno.serve(() => new Response())\n"); - await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); - - await expect(runConfigEffect(inferFunctionsManifest({ cwd }))).resolves.toEqual({ - "hello-world": { - enabled: true, - verify_jwt: true, - import_map: "./functions/hello-world/deno.json", - entrypoint: "./functions/hello-world/index.ts", - static_files: [], - env: {}, - }, - }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("keeps the default import map when config only customizes other fields", async () => { - const cwd = makeTempProject(); - - try { - const functionDir = join(cwd, "supabase", "functions", "hello-world"); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), "Deno.serve(() => new Response())\n"); - await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); - await writeFile( - join(cwd, "supabase", "config.json"), - JSON.stringify({ - functions: { + it.effect("detects default functions from the filesystem", () => + platform( + withTempProject((cwd) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(cwd, "supabase", "functions", "hello-world"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString( + path.join(functionDir, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + yield* fs.writeFileString(path.join(functionDir, "deno.json"), '{"imports":{}}\n'); + + const manifest = yield* inferFunctionsManifest({ cwd }); + expect(manifest).toEqual({ "hello-world": { + enabled: true, + verify_jwt: true, + import_map: "./functions/hello-world/deno.json", + entrypoint: "./functions/hello-world/index.ts", + static_files: [], + env: {}, + }, + }); + }), + ), + ), + ); + + it.effect("keeps the default import map when config only customizes other fields", () => + platform( + withTempProject((cwd) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(cwd, "supabase", "functions", "hello-world"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString( + path.join(functionDir, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + yield* fs.writeFileString(path.join(functionDir, "deno.json"), '{"imports":{}}\n'); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.json"), + '{"functions":{"hello-world":{"verify_jwt":false}}}', + ); + + const manifest = yield* inferFunctionsManifest({ cwd }); + expect(manifest).toEqual({ + "hello-world": { + enabled: true, verify_jwt: false, + import_map: "./functions/hello-world/deno.json", + entrypoint: "./functions/hello-world/index.ts", + static_files: [], + env: {}, }, - }, + }); }), - ); - - await expect(runConfigEffect(inferFunctionsManifest({ cwd }))).resolves.toEqual({ - "hello-world": { - enabled: true, - verify_jwt: false, - import_map: "./functions/hello-world/deno.json", - entrypoint: "./functions/hello-world/index.ts", - static_files: [], - env: {}, - }, - }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("applies config-only custom functions", async () => { - const cwd = makeTempProject(); - const config = decodeProjectConfig({ - functions: { - "custom-entrypoint": { - entrypoint: "./functions/custom-entrypoint/main.ts", - import_map: "./functions/custom-entrypoint/deno.json", - static_files: ["./functions/custom-entrypoint/*.html"], - env: { - OPENAI_API_KEY: "env(OPENAI_API_KEY)", - }, - }, - }, - }); - - try { - await expect(runConfigEffect(inferFunctionsManifest({ cwd, config }))).resolves.toEqual({ - "custom-entrypoint": { - enabled: true, - verify_jwt: true, - import_map: "./functions/custom-entrypoint/deno.json", - entrypoint: "./functions/custom-entrypoint/main.ts", - static_files: ["./functions/custom-entrypoint/*.html"], - env: { - OPENAI_API_KEY: "env(OPENAI_API_KEY)", + ), + ), + ); + + it.effect("resolves function config from an injected project environment", () => + platform( + withTempProject((cwd) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(cwd, "supabase", "functions", "hello-world"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString( + path.join(functionDir, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + `[functions.hello-world]\nentrypoint = "env(FUNCTION_ENTRYPOINT)"\n`, + ); + const projectEnv = yield* loadProjectEnvironment({ + cwd, + baseEnv: { FUNCTION_ENTRYPOINT: "./functions/hello-world/index.ts" }, + }); + if (projectEnv === null) { + return yield* Effect.die("expected a project environment"); + } + + const manifest = yield* inferFunctionsManifest({ cwd, projectEnv }); + + expect(manifest["hello-world"]?.entrypoint).toBe("./functions/hello-world/index.ts"); + }), + ), + ), + ); + + it.effect("applies config-only custom functions", () => + platform( + withTempProject((cwd) => { + const config = decodeProjectConfig({ + functions: { + "custom-entrypoint": { + entrypoint: "./functions/custom-entrypoint/main.ts", + import_map: "./functions/custom-entrypoint/deno.json", + static_files: ["./functions/custom-entrypoint/*.html"], + env: { OPENAI_API_KEY: "env(OPENAI_API_KEY)" }, + }, }, - }, - }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("uses slug defaults for config-only functions with non-path overrides", async () => { - const cwd = makeTempProject(); - const config = decodeProjectConfig({ - functions: { - "hello-world": { - verify_jwt: false, - }, - }, - }); - - try { - await expect(runConfigEffect(inferFunctionsManifest({ cwd, config }))).resolves.toEqual({ - "hello-world": { - enabled: true, - verify_jwt: false, - import_map: "", - entrypoint: "./functions/hello-world/index.ts", - static_files: [], - env: {}, - }, - }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("keeps disabled filesystem functions in the inferred manifest", async () => { - const cwd = makeTempProject(); - const config = decodeProjectConfig({ - functions: { - "hello-world": { - enabled: false, - }, - }, - }); - - try { - const functionDir = join(cwd, "supabase", "functions", "hello-world"); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), "Deno.serve(() => new Response())\n"); - - await expect(runConfigEffect(inferFunctionsManifest({ cwd, config }))).resolves.toEqual({ - "hello-world": { - enabled: false, - verify_jwt: true, - import_map: "", - entrypoint: "./functions/hello-world/index.ts", - static_files: [], - env: {}, - }, - }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("ignores directories that are not default function shapes", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase", "functions", "missing-entrypoint"), { - recursive: true, - }); - await mkdir(join(cwd, "supabase", "functions", "invalid.slug"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "functions", "invalid.slug", "index.ts"), - "Deno.serve(() => new Response())\n", - ); - - await expect(runConfigEffect(inferFunctionsManifest({ cwd }))).resolves.toEqual({}); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("search: false does not climb to an ancestor project's functions", async () => { - const projectRoot = makeTempProject(); - const nestedCwd = join(projectRoot, "nested", "workdir"); - - try { - const functionDir = join(projectRoot, "supabase", "functions", "hello-world"); - await mkdir(functionDir, { recursive: true }); - await writeFile(join(functionDir, "index.ts"), "Deno.serve(() => new Response())\n"); - await writeFile(join(projectRoot, "supabase", "config.json"), "{}\n"); - await mkdir(nestedCwd, { recursive: true }); - - await expect(runConfigEffect(inferFunctionsManifest({ cwd: nestedCwd }))).resolves.toEqual({ - "hello-world": { - enabled: true, - verify_jwt: true, - import_map: "", - entrypoint: "./functions/hello-world/index.ts", - static_files: [], - env: {}, - }, - }); - - await expect( - runConfigEffect(inferFunctionsManifest({ cwd: nestedCwd, search: false })), - ).resolves.toEqual({}); - } finally { - await rm(projectRoot, { recursive: true, force: true }); - } - }); + }); + return Effect.gen(function* () { + const manifest = yield* inferFunctionsManifest({ cwd, config }); + expect(manifest).toEqual({ + "custom-entrypoint": { + enabled: true, + verify_jwt: true, + import_map: "./functions/custom-entrypoint/deno.json", + entrypoint: "./functions/custom-entrypoint/main.ts", + static_files: ["./functions/custom-entrypoint/*.html"], + env: { OPENAI_API_KEY: "env(OPENAI_API_KEY)" }, + }, + }); + }); + }), + ), + ); + + it.effect("uses slug defaults for config-only functions with non-path overrides", () => + platform( + withTempProject((cwd) => { + const config = decodeProjectConfig({ functions: { "hello-world": { verify_jwt: false } } }); + return Effect.gen(function* () { + const manifest = yield* inferFunctionsManifest({ cwd, config }); + expect(manifest).toEqual({ + "hello-world": { + enabled: true, + verify_jwt: false, + import_map: "", + entrypoint: "./functions/hello-world/index.ts", + static_files: [], + env: {}, + }, + }); + }); + }), + ), + ); + + it.effect("keeps disabled filesystem functions in the inferred manifest", () => + platform( + withTempProject((cwd) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(cwd, "supabase", "functions", "hello-world"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString( + path.join(functionDir, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + const config = decodeProjectConfig({ functions: { "hello-world": { enabled: false } } }); + const manifest = yield* inferFunctionsManifest({ cwd, config }); + expect(manifest).toEqual({ + "hello-world": { + enabled: false, + verify_jwt: true, + import_map: "", + entrypoint: "./functions/hello-world/index.ts", + static_files: [], + env: {}, + }, + }); + }), + ), + ), + ); + + it.effect("ignores directories that are not default function shapes", () => + platform( + withTempProject((cwd) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.join(cwd, "supabase", "functions", "missing-entrypoint"), { + recursive: true, + }); + const invalid = path.join(cwd, "supabase", "functions", "invalid.slug"); + yield* fs.makeDirectory(invalid, { recursive: true }); + yield* fs.writeFileString( + path.join(invalid, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + expect(yield* inferFunctionsManifest({ cwd })).toEqual({}); + }), + ), + ), + ); + + it.effect("search: false does not climb to an ancestor project's functions", () => + platform( + withTempProject((projectRoot) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const functionDir = path.join(projectRoot, "supabase", "functions", "hello-world"); + const nestedCwd = path.join(projectRoot, "nested", "workdir"); + yield* fs.makeDirectory(functionDir, { recursive: true }); + yield* fs.writeFileString( + path.join(functionDir, "index.ts"), + "Deno.serve(() => new Response())\n", + ); + yield* fs.writeFileString(path.join(projectRoot, "supabase", "config.json"), "{}\n"); + yield* fs.makeDirectory(nestedCwd, { recursive: true }); + + expect(yield* inferFunctionsManifest({ cwd: nestedCwd })).toEqual({ + "hello-world": { + enabled: true, + verify_jwt: true, + import_map: "", + entrypoint: "./functions/hello-world/index.ts", + static_files: [], + env: {}, + }, + }); + expect(yield* inferFunctionsManifest({ cwd: nestedCwd, search: false })).toEqual({}); + }), + ), + ), + ); }); diff --git a/packages/config/src/inbucket.ts b/packages/config/src/inbucket.ts index 7070b816e0..4ca3f920d2 100644 --- a/packages/config/src/inbucket.ts +++ b/packages/config/src/inbucket.ts @@ -20,7 +20,7 @@ export const inbucket = Schema.Struct({ tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnabled))), - port: Schema.Number.annotate({ + port: Schema.Finite.annotate({ default: defaultPort, description: dedent` Port to use for the email testing server web interface. @@ -31,14 +31,14 @@ export const inbucket = Schema.Struct({ links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultPort))), smtp_port: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Optional SMTP port to expose for local testing.", tags, links, }), ), pop3_port: Schema.optionalKey( - Schema.Number.annotate({ + Schema.Finite.annotate({ description: "Optional POP3 port to expose for local testing.", tags, links, diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 16c732249f..decc6e28f7 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -1,4 +1,4 @@ -import { Console, Effect, FileSystem, Path, Redacted, Schema } from "effect"; +import { Clock, Console, Effect, FileSystem, Path, Redacted, Schema } from "effect"; import * as SmolToml from "smol-toml"; import { ProjectConfigSchema, RemotesSchema, type ProjectConfig } from "./base.ts"; import { @@ -87,10 +87,11 @@ export interface LoadProjectConfigOptions { /** * Pre-resolved project environment used to interpolate `env()` references. * When omitted, the environment is resolved internally from `.env`/`.env.local` - * layered over `process.env` (the default for most callers). Callers that need - * Go-accurate, environment-specific resolution (e.g. `functions serve`, which - * also reads `.env.<SUPABASE_ENV>` files) resolve it themselves and pass it in - * so loading does not re-read those files or depend on `process.env` mutation. + * with no ambient process environment. Runtime adapters that intentionally include + * ambient values pass them as `baseEnv` when loading the environment first, then + * provide the resulting `projectEnv`. Callers that need Go-accurate, + * environment-specific resolution (e.g. `functions serve`, which also reads + * `.env.<SUPABASE_ENV>` files) resolve it themselves and pass it in. */ readonly projectEnv?: ProjectEnvironment; /** See {@link FindProjectPathsOptions.search}. */ @@ -749,7 +750,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( ).pipe(Effect.provideService(Console.Console, globalThis.console)); } - // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env + // Substitute `env(VAR)` references against the explicitly resolved project env // before schema decode. Required for numeric/boolean fields, which would // otherwise crash the strict decoder with `Expected number` (CLI-1489). // The config file lives at `<projectRoot>/supabase/config.{toml,json}`, so @@ -760,7 +761,6 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( options?.projectEnv ?? (yield* loadProjectEnvironment({ cwd: projectRoot, - baseEnv: process.env, search: options?.search, })); const goViperCompat = options?.goViperCompat ?? false; @@ -942,7 +942,7 @@ function writeFileAtomic( ): Effect.Effect<void, never, FileSystem.FileSystem> { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - const tmpPath = `${filePath}.tmp.${Date.now()}`; + const tmpPath = `${filePath}.tmp.${yield* Clock.currentTimeMillis}`; yield* fs.writeFileString(tmpPath, content); yield* fs.rename(tmpPath, filePath); }).pipe(Effect.catchTag("PlatformError", (e) => Effect.die(e))); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 0159b251d4..fe16e0768a 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1,11 +1,19 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { afterEach, vi } from "vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Redacted, Schema } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + PlatformError, + Redacted, + Schema, + Scope, +} from "effect"; import { ProjectConfigSchema, toProjectConfigJsonSchema } from "./base.ts"; import { loadProjectConfig as loadProjectConfigFromBun } from "./bun.ts"; import { @@ -23,25 +31,18 @@ import { import { loadProjectConfig as loadProjectConfigFromNode } from "./node.ts"; import { projectConfigStoreLayer } from "./project-config.layer.ts"; import { ProjectConfigStore } from "./project-config.service.ts"; +import type { ProjectEnvironment } from "./project.ts"; import { PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; - -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-config-")); -} - -const legacyFixturePath = join( - dirname(fileURLToPath(import.meta.url)), - "../testdata/legacy-config.toml", -); - -const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); - -function runConfigEffect<A, E>( +function runConfigProgram<A, E>( effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, -): Promise<A> { - return Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); +): Effect.Effect<A, E, FileSystem.FileSystem | Path.Path> { + return effect.pipe(Effect.provide(BunServices.layer)); } - +const live = <A, E>( + name: string, + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>, +) => it.effect(name, () => effect.pipe(Effect.provide(BunServices.layer))); +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const sampleConfig = decodeProjectConfig({ project_id: "ref_123", db: { @@ -50,74 +51,89 @@ const sampleConfig = decodeProjectConfig({ }, }, }); - +function injectedProjectEnv(values: Readonly<Record<string, string>>): ProjectEnvironment { + return { + paths: { + projectRoot: "", + supabaseDir: "", + configPath: "", + envPath: "", + envLocalPath: "", + }, + values, + loadedPaths: [], + sources: {}, + }; +} describe("config io", () => { - test("saves JSON by default when no config exists", async () => { - const cwd = makeTempProject(); - - try { - const saved = await runConfigEffect(saveProjectConfig({ cwd, config: sampleConfig })); - expect(saved.format).toBe("json"); - expect(saved.path).toBe(await runConfigEffect(configJsonPath(cwd))); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads strict JSON", async () => { - const cwd = makeTempProject(); - const path = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - path, - JSON.stringify({ - project_id: "abc123", - db: { - major_version: 16, - }, + live( + "saves JSON by default when no config exists", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const saved = yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: sampleConfig, }), ); - - const loaded = await runConfigEffect(loadProjectConfigFile(path)); + expect(saved.format).toBe("json"); + expect(saved.path).toBe(yield* runConfigProgram(configJsonPath(cwd))); + }), + ); + live( + "loads strict JSON", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const path = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path, `{"project_id":"abc123","db":{"major_version":16}}`); + const loaded = yield* runConfigProgram(loadProjectConfigFile(path)); expect(loaded.format).toBe("json"); expect(loaded.config.project_id).toBe("abc123"); expect(loaded.config.db.major_version).toBe(16); expect(loaded.config.api.enabled).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads top-level $schema metadata from JSON", async () => { - const cwd = makeTempProject(); - const path = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - path, - JSON.stringify({ - $schema: PROJECT_CONFIG_SCHEMA_URL, - }), - ); - - const loaded = await runConfigEffect(loadProjectConfigFile(path)); + }), + ); + live( + "loads top-level $schema metadata from JSON", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const path = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path, `{"$schema":"${PROJECT_CONFIG_SCHEMA_URL}"}`); + const loaded = yield* runConfigProgram(loadProjectConfigFile(path)); expect(loaded.schemaRef).toBe(PROJECT_CONFIG_SCHEMA_URL); expect(loaded.config.db.major_version).toBe(17); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("rejects JSON comments and trailing commas", async () => { - const cwd = makeTempProject(); - const path = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( + }), + ); + live( + "rejects JSON comments and trailing commas", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const path = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( path, `{ // project ref @@ -128,20 +144,14 @@ describe("config io", () => { } `, ); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit( loadProjectConfigFile(path).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("decodes legacy runtime defaults from an empty config", () => { + }), + ); + it("decodes legacy runtime defaults from an empty config", () => { const config = decodeProjectConfig({}); - expect(config.api.enabled).toBe(true); expect(config.api.schemas).toEqual(["public", "graphql_public"]); expect(config.auth.site_url).toBe("http://127.0.0.1:3000"); @@ -161,8 +171,7 @@ describe("config io", () => { expect(config.functions).toEqual({}); expect(config.remotes).toEqual({}); }); - - test("requires enabled twilio fields during decode", () => { + it("requires enabled twilio fields during decode", () => { expect(() => decodeProjectConfig({ auth: { @@ -175,8 +184,7 @@ describe("config io", () => { }), ).toThrow(); }); - - test("only validates the highest-priority enabled sms provider during decode (Go switch parity)", () => { + it("only validates the highest-priority enabled sms provider during decode (Go switch parity)", () => { // Go's `(s *sms) validate()` (`apps/cli-go/pkg/config/config.go:1348-1410`) is a boolean // `switch` that inspects providers in a fixed priority order (twilio, twilio_verify, // messagebird, textlocal, vonage) and validates ONLY the first enabled one — a later @@ -200,8 +208,7 @@ describe("config io", () => { expect(config.auth.sms.twilio.enabled).toBe(true); expect(config.auth.sms.messagebird.enabled).toBe(true); }); - - test("rejects an incomplete sms provider when no higher-priority provider is enabled", () => { + it("rejects an incomplete sms provider when no higher-priority provider is enabled", () => { expect(() => decodeProjectConfig({ auth: { @@ -214,8 +221,7 @@ describe("config io", () => { }), ).toThrow(/auth\.sms\.messagebird\.originator/); }); - - test("requires enabled smtp fields during decode", () => { + it("requires enabled smtp fields during decode", () => { expect(() => decodeProjectConfig({ auth: { @@ -228,8 +234,7 @@ describe("config io", () => { }), ).toThrow(); }); - - test("decodes an unmodeled email template/notification name (Go map[string] parity)", () => { + it("decodes an unmodeled email template/notification name (Go map[string] parity)", () => { // Go's `Auth.Email.Template`/`Notification` are genuine `map[string]emailTemplate`/ // `map[string]notification` (`apps/cli-go/pkg/config/auth.go:247-248`) — open maps with no // key restriction; `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`) iterates @@ -238,16 +243,24 @@ describe("config io", () => { const config = decodeProjectConfig({ auth: { email: { - template: { custom: { subject: "Hi" } }, - notification: { custom_notice: { enabled: true, content_path: "custom.html" } }, + template: { + custom: { + subject: "Hi", + }, + }, + notification: { + custom_notice: { + enabled: true, + content_path: "custom.html", + }, + }, }, }, }); expect(config.auth.email.template["custom"]?.subject).toBe("Hi"); expect(config.auth.email.notification["custom_notice"]?.enabled).toBe(true); }); - - test("requires enabled external provider credentials during decode", () => { + it("requires enabled external provider credentials during decode", () => { expect(() => decodeProjectConfig({ auth: { @@ -260,83 +273,75 @@ describe("config io", () => { }), ).toThrow(); }); - - test("encodes sparse JSON output", () => { + it("encodes sparse JSON output", () => { const content = encodeProjectConfigToJson(sampleConfig); - expect(content).toContain('"project_id": "ref_123"'); expect(content).toContain('"pooler"'); expect(content).toContain('"enabled": true'); expect(content).not.toContain('"major_version"'); expect(content).not.toContain('"versions"'); }); - - test("encodes minimal empty configs", () => { + it("encodes minimal empty configs", () => { const config = decodeProjectConfig({}); - expect(encodeProjectConfigToJson(config)).toBe("{}\n"); expect(encodeProjectConfigToToml(config).trim()).toBe(""); }); - - test("preserves hosted $schema when saving JSON", async () => { - const cwd = makeTempProject(); - - try { - const saved = await runConfigEffect( + live( + "preserves hosted $schema when saving JSON", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const saved = yield* runConfigProgram( saveProjectConfig({ cwd, config: decodeProjectConfig({}), schemaRef: PROJECT_CONFIG_SCHEMA_URL, }), ); - expect(saved.schemaRef).toBe(PROJECT_CONFIG_SCHEMA_URL); - - const content = await readFile(saved.path, "utf8"); + const content = yield* fileSystem.readFileString(saved.path); expect(content).toContain(`"$schema": "${PROJECT_CONFIG_SCHEMA_URL}"`); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded?.schemaRef).toBe(PROJECT_CONFIG_SCHEMA_URL); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves local $schema when saving JSON over an existing config", async () => { - const cwd = makeTempProject(); - const schemaRef = "./node_modules/@supabase/config/schema.json"; - - try { - await runConfigEffect( + }), + ); + live( + "preserves local $schema when saving JSON over an existing config", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const schemaRef = "./node_modules/@supabase/config/schema.json"; + yield* runConfigProgram( saveProjectConfig({ cwd, config: decodeProjectConfig({}), schemaRef, }), ); - - const saved = await runConfigEffect( + const saved = yield* runConfigProgram( saveProjectConfig({ cwd, config: sampleConfig, }), ); - expect(saved.schemaRef).toBe(schemaRef); - - const content = await readFile(saved.path, "utf8"); + const content = yield* fileSystem.readFileString(saved.path); expect(content).toContain(`"$schema": "${schemaRef}"`); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves $schema when saving TOML", async () => { - const cwd = makeTempProject(); - const schemaRef = "./node_modules/@supabase/config/schema.json"; - - try { - const saved = await runConfigEffect( + }), + ); + live( + "preserves $schema when saving TOML", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const schemaRef = "./node_modules/@supabase/config/schema.json"; + const saved = yield* runConfigProgram( saveProjectConfig({ cwd, config: decodeProjectConfig({}), @@ -344,28 +349,28 @@ describe("config io", () => { schemaRef, }), ); - expect(saved.schemaRef).toBe(schemaRef); - - const content = await readFile(saved.path, "utf8"); + const content = yield* fileSystem.readFileString(saved.path); expect(content).toContain(`"$schema" = "${schemaRef}"`); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded?.schemaRef).toBe(schemaRef); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("prefers JSON over TOML when both exist", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); - await writeFile( + }), + ); + live( + "prefers JSON over TOML when both exist", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, encodeProjectConfigToJson(sampleConfig)); + yield* fileSystem.writeFileString( tomlPath, `project_id = "toml-ref" @@ -373,29 +378,32 @@ describe("config io", () => { major_version = 16 `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded?.format).toBe("json"); expect(loaded?.config.project_id).toBe("ref_123"); expect(loaded?.ignoredPaths).toEqual([tomlPath]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); // Go's `NewPathBuilder`/`Config.Load` (`apps/cli-go/pkg/config/utils.go: // 43-48`) only ever resolves `supabase/config.toml` — it has no concept of a // JSON project config file. Go-parity callers (legacy `status`/`stop`) pass // `tomlOnly: true` so a stray `config.json` never wins over `config.toml`. - test("loads TOML instead of JSON when tomlOnly is set, even if JSON exists", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); - await writeFile( + live( + "loads TOML instead of JSON when tomlOnly is set, even if JSON exists", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, encodeProjectConfigToJson(sampleConfig)); + yield* fileSystem.writeFileString( tomlPath, `project_id = "toml-ref" @@ -403,37 +411,49 @@ major_version = 16 major_version = 16 `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + tomlOnly: true, + }), + ); expect(loaded?.format).toBe("toml"); expect(loaded?.config.project_id).toBe("toml-ref"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("returns null when tomlOnly is set and only JSON exists", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); - - const loaded = await runConfigEffect(loadProjectConfig(cwd, { tomlOnly: true })); + }), + ); + live( + "returns null when tomlOnly is set and only JSON exists", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, encodeProjectConfigToJson(sampleConfig)); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + tomlOnly: true, + }), + ); expect(loaded).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads TOML when JSON is absent", async () => { - const cwd = makeTempProject(); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( + }), + ); + live( + "loads TOML when JSON is absent", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( tomlPath, `project_id = "toml-ref" @@ -441,56 +461,65 @@ major_version = 16 major_version = 16 `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded?.format).toBe("toml"); expect(loaded?.config.project_id).toBe("toml-ref"); expect(loaded?.config.db.major_version).toBe(16); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads the legacy CLI fixture", async () => { - const loaded = await runConfigEffect(loadProjectConfigFile(legacyFixturePath)); - const production = loaded.config.remotes.production; - const staging = loaded.config.remotes.staging; - - expect(loaded.format).toBe("toml"); - expect(loaded.config.project_id).toBe("test"); - expect(loaded.config.auth.hook.send_sms.secrets).toBe("env(AUTH_SEND_SMS_SECRETS)"); - expect(loaded.config.edge_runtime.secrets?.test_key).toBe("test_value"); - expect(loaded.config.storage.analytics.buckets).toEqual({ "my-warehouse": {} }); - expect(production).toBeDefined(); - expect(staging).toBeDefined(); - if (!production || !staging) { - throw new Error("Expected legacy remotes to be loaded."); - } - expect(production.project_id).toBe("vpefcjyosynxeiebfscx"); - expect(production.auth.site_url).toBe("http://feature-auth-branch.com/"); - expect(staging.storage?.buckets?.images?.allowed_mime_types).toEqual(["image/png"]); - }); - - test("returns null when no config file exists", async () => { - const cwd = makeTempProject(); - - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + }), + ); + live( + "loads the legacy CLI fixture", + Effect.gen(function* () { + const pathService = yield* Path.Path; + const legacyFixturePath = yield* pathService.fromFileUrl( + new URL("../testdata/legacy-config.toml", import.meta.url), + ); + const loaded = yield* runConfigProgram(loadProjectConfigFile(legacyFixturePath)); + const production = loaded.config.remotes.production; + const staging = loaded.config.remotes.staging; + expect(loaded.format).toBe("toml"); + expect(loaded.config.project_id).toBe("test"); + expect(loaded.config.auth.hook.send_sms.secrets).toBe("env(AUTH_SEND_SMS_SECRETS)"); + expect(loaded.config.edge_runtime.secrets?.test_key).toBe("test_value"); + expect(loaded.config.storage.analytics.buckets).toEqual({ + "my-warehouse": {}, + }); + expect(production).toBeDefined(); + expect(staging).toBeDefined(); + if (!production || !staging) { + throw new Error("Expected legacy remotes to be loaded."); + } + expect(production.project_id).toBe("vpefcjyosynxeiebfscx"); + expect(production.auth.site_url).toBe("http://feature-auth-branch.com/"); + expect(staging.storage?.buckets?.images?.allowed_mime_types).toEqual(["image/png"]); + }), + ); + live( + "returns null when no config file exists", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("does not ignore an invalid JSON config when TOML also exists", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, `{"project_id": 123}`); - await writeFile( + }), + ); + live( + "does not ignore an invalid JSON config when TOML also exists", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, `{"project_id": 123}`); + yield* fileSystem.writeFileString( tomlPath, `project_id = "toml-ref" @@ -498,25 +527,26 @@ major_version = 16 major_version = 16 `, ); - - await expect(runConfigEffect(loadProjectConfig(cwd))).rejects.toThrow(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("returns a typed parse error for invalid JSON", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, `{"project_id": 123}`); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit(loadProjectConfig(cwd)); + expect(Exit.isFailure(exit)).toBe(true); + }), + ); + live( + "returns a typed parse error for invalid JSON", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, `{"project_id": 123}`); + const exit = yield* Effect.exit( loadProjectConfigFile(jsonPath).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const error = Cause.findErrorOption(exit.cause); @@ -529,24 +559,27 @@ major_version = 16 } } } - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("redacts edge_runtime.secrets on the ProjectConfigParseError document", async () => { - const cwd = makeTempProject(); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); + }), + ); + live( + "redacts edge_runtime.secrets on the ProjectConfigParseError document", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); // `analytics.port` fails schema decode (expects a number), which is // enough to fail the whole `Schema.decodeUnknownSync` call while // `edge_runtime.secrets` parses fine on its own — the scenario // `recoverEdgeRuntimeConfig` (apps/cli's `secrets set`) exists to // recover from. `MY_SUPER_SECRET_VALUE` stands in for a real secret so // the assertion below can confirm it never appears in plaintext. - await writeFile( + yield* fileSystem.writeFileString( tomlPath, `[analytics] port = "not-a-number" @@ -555,11 +588,9 @@ port = "not-a-number" FOO = "MY_SUPER_SECRET_VALUE" `, ); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit( loadProjectConfigFile(tomlPath).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { return; @@ -569,7 +600,6 @@ FOO = "MY_SUPER_SECRET_VALUE" if (!Option.isSome(error) || error.value._tag !== "ProjectConfigParseError") { return; } - const edgeRuntime = error.value.document?.edge_runtime; const secrets = edgeRuntime !== null && typeof edgeRuntime === "object" && edgeRuntime !== undefined @@ -582,23 +612,29 @@ FOO = "MY_SUPER_SECRET_VALUE" // The whole point: a caller that doesn't know to unwrap `Redacted` // (e.g. an uncaught error serialized into a log) never sees the raw // secret, even via JSON.stringify. - expect(JSON.stringify(error.value.document)).not.toContain("MY_SUPER_SECRET_VALUE"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("redacts a non-string edge_runtime.secrets value on the ProjectConfigParseError document", async () => { - const cwd = makeTempProject(); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); + const encodedDocument = yield* Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.Unknown), + )(error.value.document); + expect(encodedDocument).not.toContain("MY_SUPER_SECRET_VALUE"); + }), + ); + live( + "redacts a non-string edge_runtime.secrets value on the ProjectConfigParseError document", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); // `FOO` is a TOML array, not a string — the schema decode for this // entry fails, but the raw pre-decode value still carries // `MY_SUPER_SECRET_VALUE` in plaintext. `redactEdgeRuntimeSecrets` must // wrap the entry regardless of its shape, not just string entries. - await writeFile( + yield* fileSystem.writeFileString( tomlPath, `[analytics] port = "not-a-number" @@ -607,11 +643,9 @@ port = "not-a-number" FOO = ["MY_SUPER_SECRET_VALUE"] `, ); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit( loadProjectConfigFile(tomlPath).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { return; @@ -621,7 +655,6 @@ FOO = ["MY_SUPER_SECRET_VALUE"] if (!Option.isSome(error) || error.value._tag !== "ProjectConfigParseError") { return; } - const edgeRuntime = error.value.document?.edge_runtime; const secrets = edgeRuntime !== null && typeof edgeRuntime === "object" && edgeRuntime !== undefined @@ -631,23 +664,29 @@ FOO = ["MY_SUPER_SECRET_VALUE"] const foo = (secrets as Record<string, unknown>).FOO; expect(Redacted.isRedacted(foo)).toBe(true); expect(Redacted.value(foo as Redacted.Redacted<unknown>)).toEqual(["MY_SUPER_SECRET_VALUE"]); - expect(JSON.stringify(error.value.document)).not.toContain("MY_SUPER_SECRET_VALUE"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("redacts a non-object edge_runtime.secrets field on the ProjectConfigParseError document", async () => { - const cwd = makeTempProject(); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); + const encodedDocument = yield* Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.Unknown), + )(error.value.document); + expect(encodedDocument).not.toContain("MY_SUPER_SECRET_VALUE"); + }), + ); + live( + "redacts a non-object edge_runtime.secrets field on the ProjectConfigParseError document", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); // `secrets` itself is a TOML array here, not a table — the whole field // is malformed rather than a single entry inside it. `isObject` rejects // arrays, so `redactEdgeRuntimeSecrets` must wrap the field as one unit // instead of falling through its early-return and leaving it raw. - await writeFile( + yield* fileSystem.writeFileString( tomlPath, `[analytics] port = "not-a-number" @@ -656,11 +695,9 @@ port = "not-a-number" secrets = ["MY_SUPER_SECRET_VALUE"] `, ); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit( loadProjectConfigFile(tomlPath).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { return; @@ -670,7 +707,6 @@ secrets = ["MY_SUPER_SECRET_VALUE"] if (!Option.isSome(error) || error.value._tag !== "ProjectConfigParseError") { return; } - const edgeRuntime = error.value.document?.edge_runtime; const secrets = edgeRuntime !== null && typeof edgeRuntime === "object" && edgeRuntime !== undefined @@ -680,20 +716,26 @@ secrets = ["MY_SUPER_SECRET_VALUE"] expect(Redacted.value(secrets as Redacted.Redacted<unknown>)).toEqual([ "MY_SUPER_SECRET_VALUE", ]); - expect(JSON.stringify(error.value.document)).not.toContain("MY_SUPER_SECRET_VALUE"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves TOML as the active format on save", async () => { - const cwd = makeTempProject(); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( + const encodedDocument = yield* Schema.encodeUnknownEffect( + Schema.fromJsonString(Schema.Unknown), + )(error.value.document); + expect(encodedDocument).not.toContain("MY_SUPER_SECRET_VALUE"); + }), + ); + live( + "preserves TOML as the active format on save", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( tomlPath, `project_id = "old-ref" @@ -701,30 +743,35 @@ secrets = ["MY_SUPER_SECRET_VALUE"] major_version = 16 `, ); - - const saved = await runConfigEffect(saveProjectConfig({ cwd, config: sampleConfig })); - + const saved = yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: sampleConfig, + }), + ); expect(saved.format).toBe("toml"); expect(saved.path).toBe(tomlPath); - expect(await Bun.file(jsonPath).exists()).toBe(false); - const content = await readFile(tomlPath, "utf8"); + expect(yield* fileSystem.exists(jsonPath)).toBe(false); + const content = yield* fileSystem.readFileString(tomlPath); expect(content).toContain('project_id = "ref_123"'); expect(content).toContain("[db.pooler]"); expect(content).not.toContain("major_version"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves JSON as the active format on save", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); - - const saved = await runConfigEffect( + }), + ); + live( + "preserves JSON as the active format on save", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, encodeProjectConfigToJson(sampleConfig)); + const saved = yield* runConfigProgram( saveProjectConfig({ cwd, config: decodeProjectConfig({ @@ -735,73 +782,87 @@ major_version = 16 }), }), ); - expect(saved.format).toBe("json"); expect(saved.path).toBe(jsonPath); - const content = await readFile(jsonPath, "utf8"); + const content = yield* fileSystem.readFileString(jsonPath); expect(content).toContain('"project_id": "updated-ref"'); expect(content).toContain('"enable_signup": false'); expect(content).not.toContain('"jwt_expiry"'); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("supports explicit format override", async () => { - const cwd = makeTempProject(); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(jsonPath, encodeProjectConfigToJson(sampleConfig)); - - const saved = await runConfigEffect( - saveProjectConfig({ cwd, config: sampleConfig, format: "toml" }), + }), + ); + live( + "supports explicit format override", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(jsonPath, encodeProjectConfigToJson(sampleConfig)); + const saved = yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: sampleConfig, + format: "toml", + }), ); - expect(saved.format).toBe("toml"); expect(saved.path).toBe(tomlPath); - expect(await Bun.file(jsonPath).exists()).toBe(false); - const content = await readFile(tomlPath, "utf8"); + expect(yield* fileSystem.exists(jsonPath)).toBe(false); + const content = yield* fileSystem.readFileString(tomlPath); expect(content).toContain("[db.pooler]"); expect(content).not.toContain("[versions]"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("removes TOML when explicitly switching to JSON", async () => { - const cwd = makeTempProject(); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(tomlPath, encodeProjectConfigToToml(sampleConfig)); - - const saved = await runConfigEffect( - saveProjectConfig({ cwd, config: sampleConfig, format: "json" }), + }), + ); + live( + "removes TOML when explicitly switching to JSON", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(tomlPath, encodeProjectConfigToToml(sampleConfig)); + const saved = yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: sampleConfig, + format: "json", + }), ); - expect(saved.format).toBe("json"); expect(saved.path).toBe(jsonPath); - expect(await Bun.file(tomlPath).exists()).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves the discovered project format when saving from a nested cwd", async () => { - const cwd = makeTempProject(); - const nestedCwd = join(cwd, "apps", "web", "src"); - const tomlPath = await runConfigEffect(configTomlPath(cwd)); - const jsonPath = await runConfigEffect(configJsonPath(cwd)); - - try { - await mkdir(nestedCwd, { recursive: true }); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( + expect(yield* fileSystem.exists(tomlPath)).toBe(false); + }), + ); + live( + "preserves the discovered project format when saving from a nested cwd", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const nestedCwd = pathService.join(cwd, "apps", "web", "src"); + const tomlPath = yield* runConfigProgram(configTomlPath(cwd)); + const jsonPath = yield* runConfigProgram(configJsonPath(cwd)); + yield* fileSystem.makeDirectory(nestedCwd, { + recursive: true, + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( tomlPath, `project_id = "nested-ref" @@ -809,8 +870,7 @@ major_version = 16 major_version = 16 `, ); - - const saved = await runConfigEffect( + const saved = yield* runConfigProgram( saveProjectConfig({ cwd: nestedCwd, config: decodeProjectConfig({ @@ -818,114 +878,122 @@ major_version = 16 }), }), ); - expect(saved.format).toBe("toml"); expect(saved.path).toBe(tomlPath); - expect(await Bun.file(jsonPath).exists()).toBe(false); - const content = await readFile(tomlPath, "utf8"); + expect(yield* fileSystem.exists(jsonPath)).toBe(false); + const content = yield* fileSystem.readFileString(tomlPath); expect(content).toContain('project_id = "nested-updated"'); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("exposes a ProjectConfigStore service for the CLI", async () => { - const cwd = makeTempProject(); - const layer = projectConfigStoreLayer.pipe(Layer.provide(BunServices.layer)); - - try { - const loaded = await Effect.runPromise( - Effect.gen(function* () { - const store = yield* ProjectConfigStore; - yield* store.save({ cwd, config: sampleConfig }); - return yield* store.load(cwd); - }).pipe(Effect.provide(layer)), - ); - + }), + ); + live( + "exposes a ProjectConfigStore service for the CLI", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const layer = projectConfigStoreLayer.pipe(Layer.provide(BunServices.layer)); + const loaded = yield* Effect.gen(function* () { + const store = yield* ProjectConfigStore; + yield* store.save({ + cwd, + config: sampleConfig, + }); + return yield* store.load(cwd); + }).pipe(Effect.provide(layer)); expect(loaded?.config.project_id).toBe("ref_123"); expect(loaded?.config.db.pooler.enabled).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("encodes sparse TOML for fresh output", () => { + }), + ); + it("encodes sparse TOML for fresh output", () => { const content = encodeProjectConfigToToml(sampleConfig); expect(content).toContain('project_id = "ref_123"'); expect(content).toContain("[db.pooler]"); expect(content).not.toContain("major_version"); expect(content).not.toContain("[versions]"); }); - - test("supports the Bun edge entrypoint", async () => { - const cwd = makeTempProject(); - - try { - await saveProjectConfig({ cwd, config: sampleConfig }).pipe( - Effect.provide(BunServices.layer), - Effect.runPromise, + live( + "supports the Bun edge entrypoint", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: sampleConfig, + }), ); - const loaded = await loadProjectConfigFromBun(cwd); + const loaded = yield* Effect.promise(() => loadProjectConfigFromBun(cwd)); expect(loaded?.config.project_id).toBe("ref_123"); expect(loaded?.config.db.pooler.enabled).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("supports the Node edge entrypoint", async () => { - const cwd = makeTempProject(); - - try { - await saveProjectConfig({ cwd, config: sampleConfig }).pipe( - Effect.provide(BunServices.layer), - Effect.runPromise, + }), + ); + live( + "supports the Node edge entrypoint", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: sampleConfig, + }), ); - const loaded = await loadProjectConfigFromNode(cwd); + const loaded = yield* Effect.promise(() => loadProjectConfigFromNode(cwd)); expect(loaded?.config.project_id).toBe("ref_123"); expect(loaded?.config.db.pooler.enabled).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("round-trip: save → load → save produces identical config and file content", async () => { - const cwd = makeTempProject(); - - try { + }), + ); + live( + "round-trip: save → load → save produces identical config and file content", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); const original = decodeProjectConfig({ project_id: "roundtrip-ref", db: { major_version: 16, - pooler: { enabled: true }, + pooler: { + enabled: true, + }, }, auth: { enable_signup: false, site_url: "https://example.com", }, - analytics: { enabled: false }, + analytics: { + enabled: false, + }, }); - - const saved1 = await runConfigEffect(saveProjectConfig({ cwd, config: original })); - const content1 = await readFile(saved1.path, "utf8"); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const saved1 = yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: original, + }), + ); + const content1 = yield* fileSystem.readFileString(saved1.path); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded).not.toBeNull(); expect(loaded!.config).toEqual(original); - - const saved2 = await runConfigEffect(saveProjectConfig({ cwd, config: loaded!.config })); - const content2 = await readFile(saved2.path, "utf8"); - + const saved2 = yield* runConfigProgram( + saveProjectConfig({ + cwd, + config: loaded!.config, + }), + ); + const content2 = yield* fileSystem.readFileString(saved2.path); expect(content2).toBe(content1); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("includes current keys in generated JSON schema", () => { + }), + ); + it("includes current keys in generated JSON schema", () => { const schema = toProjectConfigJsonSchema(); - const schemaString = JSON.stringify(schema); - + const schemaString = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(schema); expect(schemaString).toContain("local_smtp"); expect(schemaString).toContain("remotes"); expect(schemaString).toContain("static_files"); @@ -935,14 +1003,19 @@ major_version = 16 expect(schemaString.toLowerCase()).not.toContain("inbucket"); expect(schemaString).not.toContain("versions"); }); - - test("resolves env() on numeric port fields (CLI-1489)", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + live( + "resolves env() on numeric port fields (CLI-1489)", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [api] @@ -955,45 +1028,45 @@ port = "env(SUPABASE_DB_PORT)" port = "env(SUPABASE_ANALYTICS_PORT)" `, ); - await writeFile( - join(cwd, "supabase", ".env"), + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", ".env"), "SUPABASE_API_PORT=54321\nSUPABASE_DB_PORT=54322\nSUPABASE_ANALYTICS_PORT=54327\n", ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); - + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded).not.toBeNull(); expect(loaded!.config.api.port).toBe(54321); expect(loaded!.config.db.port).toBe(54322); expect(loaded!.config.analytics.port).toBe(54327); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolves env() on boolean fields", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "resolves env() on boolean fields", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [analytics] enabled = "env(SUPABASE_ANALYTICS_ENABLED)" `, ); - await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_ANALYTICS_ENABLED=false\n"); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", ".env"), + "SUPABASE_ANALYTICS_ENABLED=false\n", + ); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.config.analytics.enabled).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test.each([ + }), + ); + it.effect.each([ ["1", true], ["TRUE", true], ["T", true], @@ -1003,231 +1076,279 @@ enabled = "env(SUPABASE_ANALYTICS_ENABLED)" ["FALSE", false], ] as const)( "resolves env() on boolean fields using Go's strconv.ParseBool acceptance set (%s -> %s)", - async (envValue, expected) => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + ([envValue, expected]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [analytics] enabled = "env(SUPABASE_ANALYTICS_ENABLED)" `, ); - await writeFile(join(cwd, "supabase", ".env"), `SUPABASE_ANALYTICS_ENABLED=${envValue}\n`); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", ".env"), + `SUPABASE_ANALYTICS_ENABLED=${String(envValue)}\n`, + ); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.config.analytics.enabled).toBe(expected); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }, + }).pipe(Effect.provide(BunServices.layer)), ); - - test("splits a comma-separated string literal into a slice (Go's StringToSliceHookFunc)", async () => { - // Go's `newDecodeHook` (`apps/cli-go/pkg/config/config.go:775-784`) wires - // `mapstructure.StringToSliceHookFunc(",")` unconditionally, so a plain - // string value for a `[]string` field like `additional_redirect_urls` - // decodes fine in Go — not just via `env(...)`. - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + live( + "splits a comma-separated string literal into a slice (Go's StringToSliceHookFunc)", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + // Go's `newDecodeHook` (`apps/cli-go/pkg/config/config.go:775-784`) wires + // `mapstructure.StringToSliceHookFunc(",")` unconditionally, so a plain + // string value for a `[]string` field like `additional_redirect_urls` + // decodes fine in Go — not just via `env(...)`. + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] additional_redirect_urls = "http://a,http://b" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + goViperCompat: true, + }), + ); expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("splits an env()-substituted comma-separated string into a slice", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "splits an env()-substituted comma-separated string into a slice", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] additional_redirect_urls = "env(SUPABASE_REDIRECT_URLS)" `, ); - await writeFile(join(cwd, "supabase", ".env"), "SUPABASE_REDIRECT_URLS=http://a,http://b\n"); - - const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", ".env"), + "SUPABASE_REDIRECT_URLS=http://a,http://b\n", + ); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + goViperCompat: true, + }), + ); expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("an empty string literal for a slice field decodes to an empty array", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "an empty string literal for a slice field decodes to an empty array", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] additional_redirect_urls = "" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + goViperCompat: true, + }), + ); expect(loaded!.config.auth.additional_redirect_urls).toEqual([]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("an actual array value for a slice field is left untouched", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "an actual array value for a slice field is left untouched", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] additional_redirect_urls = ["http://a", "http://b"] `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.config.auth.additional_redirect_urls).toEqual(["http://a", "http://b"]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves env() literals on string fields when the var is unset (Go parity)", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "preserves env() literals on string fields when the var is unset (Go parity)", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] jwt_secret = "env(MISSING_SECRET)" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves env() literals on string fields when the var is set but empty (Go parity)", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "preserves env() literals on string fields when the var is set but empty (Go parity)", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] jwt_secret = "env(MISSING_SECRET)" `, ); - await writeFile(join(cwd, "supabase", ".env"), "MISSING_SECRET=\n"); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", ".env"), + "MISSING_SECRET=\n", + ); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.config.auth.jwt_secret).toBe("env(MISSING_SECRET)"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("fails to decode a numeric field when env var is unset", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "fails to decode a numeric field when env var is unset", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [analytics] port = "env(MISSING_PORT)" `, ); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit( loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure)).toBe(true); if (Option.isSome(failure)) { - expect((failure.value as { _tag: string })._tag).toBe("ProjectConfigParseError"); + expect( + ( + failure.value as { + _tag: string; + } + )._tag, + ).toBe("ProjectConfigParseError"); } } - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("falls back to ambient process.env when .env is missing", async () => { - const cwd = makeTempProject(); - const previous = process.env.SUPABASE_DB_PORT_TEST; - process.env.SUPABASE_DB_PORT_TEST = "55555"; - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "falls back to ambient process.env when .env is missing", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [db] port = "env(SUPABASE_DB_PORT_TEST)" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectEnv: injectedProjectEnv({ + SUPABASE_DB_PORT_TEST: "55555", + }), + }), + ); expect(loaded!.config.db.port).toBe(55555); - } finally { - if (previous === undefined) { - delete process.env.SUPABASE_DB_PORT_TEST; - } else { - process.env.SUPABASE_DB_PORT_TEST = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); // Regression coverage for the default-off (`goViperCompat` omitted) path — // these pin pre-PR-#5765 behavior so `next/`, `packages/stack`, and the // functions manifest (none of which pass `goViperCompat`) don't inherit the // Go-parity legacy shell's stricter/wider semantics. - test("loads successfully with a duplicate [remotes.*] project_id when goViperCompat is omitted", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + live( + "loads successfully with a duplicate [remotes.*] project_id when goViperCompat is omitted", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "baseref" [remotes.a] @@ -1237,150 +1358,174 @@ project_id = "dupref" project_id = "dupref" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded).not.toBeNull(); expect(loaded!.config.project_id).toBe("baseref"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "loads successfully with an invalid [remotes.*] project_id format when goViperCompat is omitted", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "baseref" [remotes.bad] project_id = "not-a-ref" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded).not.toBeNull(); expect(loaded!.config.project_id).toBe("baseref"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("does not split a comma-separated string literal for an array field when goViperCompat is omitted", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "does not split a comma-separated string literal for an array field when goViperCompat is omitted", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "ref_123" [auth] additional_redirect_urls = "http://a,http://b" `, ); - - const exit = await Effect.runPromiseExit( + const exit = yield* Effect.exit( loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), ); - expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const error = Cause.findErrorOption(exit.cause); expect(Option.isSome(error)).toBe(true); if (Option.isSome(error)) { - expect((error.value as { _tag: string })._tag).toBe("ProjectConfigParseError"); + expect( + ( + error.value as { + _tag: string; + } + )._tag, + ).toBe("ProjectConfigParseError"); } } - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("does not warn on a deprecated provider (but still strips it) when goViperCompat is omitted", async () => { - const cwd = makeTempProject(); - const warnings: Array<string> = []; - const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { - warnings.push(args.map((a) => String(a)).join(" ")); - }); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "does not warn on a deprecated provider (but still strips it) when goViperCompat is omitted", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const warnings: Array<string> = []; + vi.spyOn(console, "error").mockImplementation((...args) => { + warnings.push(args.map((a) => String(a)).join(" ")); + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "abc123" [auth.external.slack] enabled = true `, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect("slack" in loaded!.config.auth.external).toBe(false); expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - } finally { - errorSpy.mockRestore(); - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("does not resolve a lowercase-named env() reference when goViperCompat is omitted", async () => { - const previous = process.env.lowercase_ref_default_off_test; - process.env.lowercase_ref_default_off_test = "lowercase-ref-value"; - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + vi.restoreAllMocks(); + }), + ); + live( + "does not resolve a lowercase-named env() reference when goViperCompat is omitted", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "env(lowercase_ref_default_off_test)"\n`, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectEnv: injectedProjectEnv({ + lowercase_ref_default_off_test: "lowercase-ref-value", + }), + }), + ); expect(loaded!.config.project_id).toBe("env(lowercase_ref_default_off_test)"); - } finally { - if (previous === undefined) { - delete process.env.lowercase_ref_default_off_test; - } else { - process.env.lowercase_ref_default_off_test = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolves a lowercase-named env() reference when goViperCompat is true", async () => { - const previous = process.env.lowercase_ref_default_on_test; - process.env.lowercase_ref_default_on_test = "lowercase-ref-value"; - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "config.toml"), + }), + ); + live( + "resolves a lowercase-named env() reference when goViperCompat is true", + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString( + pathService.join(cwd, "supabase", "config.toml"), `project_id = "env(lowercase_ref_default_on_test)"\n`, ); - - const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + goViperCompat: true, + projectEnv: injectedProjectEnv({ + lowercase_ref_default_on_test: "lowercase-ref-value", + }), + }), + ); expect(loaded!.config.project_id).toBe("lowercase-ref-value"); - } finally { - if (previous === undefined) { - delete process.env.lowercase_ref_default_on_test; - } else { - process.env.lowercase_ref_default_on_test = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); }); - describe("config io [remotes.*] merge", () => { - async function writeTomlProject(toml: string): Promise<string> { - const cwd = makeTempProject(); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), toml); - return cwd; + function writeTomlProject( + toml: string, + ): Effect.Effect< + string, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | Scope.Scope + > { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(pathService.join(cwd, "supabase", "config.toml"), toml); + return cwd; + }); } // Remote `project_id`s below are valid 20-lowercase-letter refs (Go's @@ -1390,7 +1535,6 @@ describe("config io [remotes.*] merge", () => { // even for scenarios that don't care about the ref's specific value. const PREVIEW_REF = "previewrefaaaaaaaaaa"; const STAGING_REF = "stagingrefaaaaaaaaaa"; - const BASE_WITH_REMOTES = `project_id = "baseref" [api] @@ -1412,126 +1556,112 @@ project_id = "${STAGING_REF}" [remotes.staging.api] enabled = false `; - function originAt(loaded: LoadedProjectConfig | null | undefined, path: ReadonlyArray<string>) { return loaded === null || loaded === undefined ? undefined : projectConfigValueSourceAt(loaded, path); } - - function injectedProjectEnv(values: Readonly<Record<string, string>>) { - return { - paths: { - projectRoot: "", - supabaseDir: "", - configPath: "", - envPath: "", - envLocalPath: "", - }, - values, - loadedPaths: [], - sources: {}, - }; - } - - test("tracks the source of effective local, remote, and environment values", async () => { - const localCwd = await writeTomlProject(`project_id = "baseref" + live( + "tracks the source of effective local, remote, and environment values", + Effect.gen(function* () { + const localCwd = yield* writeTomlProject(`project_id = "baseref" [api] port = 6001 `); - const envCwd = await writeTomlProject(`project_id = "baseref" + const envCwd = yield* writeTomlProject(`project_id = "baseref" [api] port = "env(API_PORT)" `); - const remoteCwd = await writeTomlProject(`project_id = "baseref" + const remoteCwd = yield* writeTomlProject(`project_id = "baseref" [remotes.preview] project_id = "${PREVIEW_REF}" [remotes.preview.db] port = 6002 `); - const remoteEnvCwd = await writeTomlProject(`project_id = "baseref" + const remoteEnvCwd = yield* writeTomlProject(`project_id = "baseref" [remotes.preview] project_id = "${PREVIEW_REF}" [remotes.preview.db] port = "env(REMOTE_DB_PORT)" `); - const omittedCwd = await writeTomlProject(`project_id = "baseref" + const omittedCwd = yield* writeTomlProject(`project_id = "baseref" `); - - try { - const loaded = await runConfigEffect(loadProjectConfig(localCwd)); - const envLoaded = await runConfigEffect( - loadProjectConfig(envCwd, { projectEnv: injectedProjectEnv({ API_PORT: "6001" }) }), + const loaded = yield* runConfigProgram(loadProjectConfig(localCwd)); + const envLoaded = yield* runConfigProgram( + loadProjectConfig(envCwd, { + projectEnv: injectedProjectEnv({ + API_PORT: "6001", + }), + }), ); - const remoteLoaded = await runConfigEffect( - loadProjectConfig(remoteCwd, { projectRef: PREVIEW_REF }), + const remoteLoaded = yield* runConfigProgram( + loadProjectConfig(remoteCwd, { + projectRef: PREVIEW_REF, + }), ); - const remoteEnvLoaded = await runConfigEffect( + const remoteEnvLoaded = yield* runConfigProgram( loadProjectConfig(remoteEnvCwd, { projectRef: PREVIEW_REF, - projectEnv: injectedProjectEnv({ REMOTE_DB_PORT: "6003" }), + projectEnv: injectedProjectEnv({ + REMOTE_DB_PORT: "6003", + }), }), ); - const omittedLoaded = await runConfigEffect(loadProjectConfig(omittedCwd)); - + const omittedLoaded = yield* runConfigProgram(loadProjectConfig(omittedCwd)); expect(originAt(loaded, ["api", "port"])).toBe("local"); expect(originAt(envLoaded, ["api", "port"])).toBe("environment"); expect(originAt(remoteLoaded, ["db", "port"])).toBe("remote"); expect(originAt(remoteEnvLoaded, ["db", "port"])).toBe("environment"); expect(originAt(omittedLoaded, ["studio", "port"])).toBeUndefined(); - } finally { - await Promise.all( - [localCwd, envCwd, remoteCwd, remoteEnvCwd, omittedCwd].map((cwd) => - rm(cwd, { recursive: true, force: true }), - ), - ); - } - }); - - test("tracks environment origins for local and selected-remote array leaves", async () => { - const localArrayCwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "tracks environment origins for local and selected-remote array leaves", + Effect.gen(function* () { + const localArrayCwd = yield* writeTomlProject(`project_id = "baseref" [api] schemas = ["env(LOCAL_SCHEMA)"] `); - const remoteArrayCwd = await writeTomlProject(`project_id = "baseref" + const remoteArrayCwd = yield* writeTomlProject(`project_id = "baseref" [remotes.preview] project_id = "${PREVIEW_REF}" [remotes.preview.api] schemas = ["env(REMOTE_SCHEMA)"] `); - - try { - const localArrayLoaded = await runConfigEffect( + const localArrayLoaded = yield* runConfigProgram( loadProjectConfig(localArrayCwd, { - projectEnv: injectedProjectEnv({ LOCAL_SCHEMA: "local_schema" }), + projectEnv: injectedProjectEnv({ + LOCAL_SCHEMA: "local_schema", + }), }), ); - const remoteArrayLoaded = await runConfigEffect( + const remoteArrayLoaded = yield* runConfigProgram( loadProjectConfig(remoteArrayCwd, { projectRef: PREVIEW_REF, - projectEnv: injectedProjectEnv({ REMOTE_SCHEMA: "remote_schema" }), + projectEnv: injectedProjectEnv({ + REMOTE_SCHEMA: "remote_schema", + }), }), ); - expect(originAt(localArrayLoaded, ["api", "schemas"])).toBe("environment"); expect(originAt(remoteArrayLoaded, ["api", "schemas"])).toBe("environment"); - } finally { - await Promise.all( - [localArrayCwd, remoteArrayCwd].map((cwd) => rm(cwd, { recursive: true, force: true })), + }), + ); + live( + "merges the matching remote subtree over the base before decode", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(BASE_WITH_REMOTES); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + }), ); - } - }); - - test("merges the matching remote subtree over the base before decode", async () => { - const cwd = await writeTomlProject(BASE_WITH_REMOTES); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.appliedRemote).toBe("preview"); // remote block's project_id overrides the base expect(loaded!.config.project_id).toBe(PREVIEW_REF); @@ -1545,27 +1675,25 @@ schemas = ["env(REMOTE_SCHEMA)"] expect(loaded!.config.db.major_version).toBe(15); // remotes are stripped from the merged document before decode expect(loaded!.document?.remotes).toBeUndefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("carries appliedRemote on ProjectConfigParseError when the matched remote's decode fails", async () => { - // Go prints `Loading config override: [remotes.<name>]` unconditionally - // as soon as the `project_id` match is found, *before* `mapstructure` - // decode runs (`apps/cli-go/pkg/config/config.go:604-609`) — so the notice - // is still owed even when the decode that follows fails. `db.major_version` - // is an unrelated schema-decode error; the remote merge must still have - // happened (and be reported) ahead of it. - const cwd = await writeTomlProject( - `${BASE_WITH_REMOTES} + }), + ); + live( + "carries appliedRemote on ProjectConfigParseError when the matched remote's decode fails", + Effect.gen(function* () { + // Go prints `Loading config override: [remotes.<name>]` unconditionally + // as soon as the `project_id` match is found, *before* `mapstructure` + // decode runs (`apps/cli-go/pkg/config/config.go:604-609`) — so the notice + // is still owed even when the decode that follows fails. `db.major_version` + // is an unrelated schema-decode error; the remote merge must still have + // happened (and be reported) ahead of it. + const cwd = yield* writeTomlProject(`${BASE_WITH_REMOTES} [remotes.preview.db] major_version = "not-a-number" -`, - ); - try { - const exit = await Effect.runPromiseExit( - loadProjectConfig(cwd, { projectRef: PREVIEW_REF }).pipe(Effect.provide(BunServices.layer)), +`); + const exit = yield* Effect.exit( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + }).pipe(Effect.provide(BunServices.layer)), ); expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -1577,47 +1705,46 @@ major_version = "not-a-number" return; } expect(error.value.appliedRemote).toBe("preview"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads the base config verbatim when no remote matches", async () => { - const cwd = await writeTomlProject(BASE_WITH_REMOTES); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: "unknownref" })); + }), + ); + live( + "loads the base config verbatim when no remote matches", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(BASE_WITH_REMOTES); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: "unknownref", + }), + ); expect(loaded!.appliedRemote).toBeUndefined(); expect(loaded!.config.project_id).toBe("baseref"); expect(loaded!.config.api.max_rows).toBe(123); expect(loaded!.config.api.schemas).toEqual(["public", "custom_base"]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("does not merge remotes when no projectRef is requested and none has an empty project_id", async () => { - // `projectRef` defaults to "" (Go's own `Config.ProjectId` default for - // commands with no `--project-ref` flag), so this only stays unmerged - // because neither remote's `project_id` is empty. - const cwd = await writeTomlProject(BASE_WITH_REMOTES); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + }), + ); + live( + "does not merge remotes when no projectRef is requested and none has an empty project_id", + Effect.gen(function* () { + // `projectRef` defaults to "" (Go's own `Config.ProjectId` default for + // commands with no `--project-ref` flag), so this only stays unmerged + // because neither remote's `project_id` is empty. + const cwd = yield* writeTomlProject(BASE_WITH_REMOTES); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.appliedRemote).toBeUndefined(); expect(loaded!.config.api.max_rows).toBe(123); expect(Object.keys(loaded!.config.remotes)).toEqual(["preview", "staging"]); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("rejects duplicate project_id across remotes even when no projectRef is requested", async () => { - // Go's duplicate-project_id check (config.go:594-602) runs unconditionally - // on every config load, inside the same loop that resolves the [remotes.*] - // override — it is not gated on a caller actually selecting a remote. - // status/stop (internal/utils/flags/config_path.go:11) never bind a - // `--project-ref` flag, so they hit this check with `Config.ProjectId == ""`, - // and it must still fail on a config-wide duplicate. - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "rejects duplicate project_id across remotes even when no projectRef is requested", + Effect.gen(function* () { + // Go's duplicate-project_id check (config.go:594-602) runs unconditionally + // on every config load, inside the same loop that resolves the [remotes.*] + // override — it is not gated on a caller actually selecting a remote. + // status/stop (internal/utils/flags/config_path.go:11) never bind a + // `--project-ref` flag, so they hit this check with `Config.ProjectId == ""`, + // and it must still fail on a config-wide duplicate. + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.a] project_id = "dupref" @@ -1625,20 +1752,15 @@ project_id = "dupref" [remotes.b] project_id = "dupref" `); - try { - const message = await Effect.runPromise( - loadProjectConfig(cwd, { goViperCompat: true }).pipe( - Effect.catchTag("DuplicateRemoteProjectIdError", (error) => - Effect.succeed(error.message), - ), - Effect.provide(BunServices.layer), - ), + const message = yield* loadProjectConfig(cwd, { + goViperCompat: true, + }).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), ); expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); // `goViperCompat` is required even though a `projectRef` is passed: the // duplicate/format checks in `applyRemoteOverride` are gated solely on @@ -1646,8 +1768,10 @@ project_id = "dupref" // match/merge itself stays unconditional, but pre-PR-#5765 callers that // pass a `projectRef` without opting into Go parity no longer get these // checks for free. - test("rejects duplicate project_id across remotes with Go's message", async () => { - const cwd = await writeTomlProject(`project_id = "baseref" + live( + "rejects duplicate project_id across remotes with Go's message", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.a] project_id = "dupref" @@ -1655,26 +1779,23 @@ project_id = "dupref" [remotes.b] project_id = "dupref" `); - try { - const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "dupref", goViperCompat: true }).pipe( - Effect.catchTag("DuplicateRemoteProjectIdError", (error) => - Effect.succeed(error.message), - ), - Effect.provide(BunServices.layer), - ), + const message = yield* loadProjectConfig(cwd, { + projectRef: "dupref", + goViperCompat: true, + }).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), ); expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("rejects duplicate project_id among remotes that do not match projectRef", async () => { - // Go builds the duplicate map across all [remotes.*] blocks before applying the - // matching override, so a clash between two non-target remotes still fails even - // though neither shares projectRef (config.go:503-518). - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "rejects duplicate project_id among remotes that do not match projectRef", + Effect.gen(function* () { + // Go builds the duplicate map across all [remotes.*] blocks before applying the + // matching override, so a clash between two non-target remotes still fails even + // though neither shares projectRef (config.go:503-518). + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.target] project_id = "previewref" @@ -1685,25 +1806,22 @@ project_id = "dupref" [remotes.b] project_id = "dupref" `); - try { - const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( - Effect.catchTag("DuplicateRemoteProjectIdError", (error) => - Effect.succeed(error.message), - ), - Effect.provide(BunServices.layer), - ), + const message = yield* loadProjectConfig(cwd, { + projectRef: "previewref", + goViperCompat: true, + }).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), ); expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("rejects two remotes that both omit project_id", async () => { - // A missing project_id reads as "" (Go's viper.GetString), so two remotes that - // both omit it collide on the empty key. - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "rejects two remotes that both omit project_id", + Effect.gen(function* () { + // A missing project_id reads as "" (Go's viper.GetString), so two remotes that + // both omit it collide on the empty key. + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.a] [remotes.a.api] @@ -1713,67 +1831,64 @@ max_rows = 1 [remotes.b.api] max_rows = 2 `); - try { - const message = await Effect.runPromise( - loadProjectConfig(cwd, { projectRef: "previewref", goViperCompat: true }).pipe( - Effect.catchTag("DuplicateRemoteProjectIdError", (error) => - Effect.succeed(error.message), - ), - Effect.provide(BunServices.layer), - ), + const message = yield* loadProjectConfig(cwd, { + projectRef: "previewref", + goViperCompat: true, + }).pipe( + Effect.catchTag("DuplicateRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), ); expect(message).toBe("duplicate project_id for [remotes.b] and [remotes.a]"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("rejects a remote project_id that is not a valid 20-letter ref, even with no projectRef requested", async () => { - // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id - // against refPattern unconditionally on every config load — not only the one - // that ends up selected — so this must fail closed before status/stop reach - // Docker, exactly like Go, even when the caller never selects a remote. - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "rejects a remote project_id that is not a valid 20-letter ref, even with no projectRef requested", + Effect.gen(function* () { + // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id + // against refPattern unconditionally on every config load — not only the one + // that ends up selected — so this must fail closed before status/stop reach + // Docker, exactly like Go, even when the caller never selects a remote. + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.bad] project_id = "not-a-ref" `); - try { - const message = await Effect.runPromise( - loadProjectConfig(cwd, { goViperCompat: true }).pipe( - Effect.catchTag("InvalidRemoteProjectIdError", (error) => Effect.succeed(error.message)), - Effect.provide(BunServices.layer), - ), + const message = yield* loadProjectConfig(cwd, { + goViperCompat: true, + }).pipe( + Effect.catchTag("InvalidRemoteProjectIdError", (error) => Effect.succeed(error.message)), + Effect.provide(BunServices.layer), ); expect(message).toBe( "Invalid config for remotes.bad.project_id. Must be like: abcdefghijklmnopqrst", ); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("the merged document carries pointer sections introduced by the remote", async () => { - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "the merged document carries pointer sections introduced by the remote", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.preview] project_id = "${PREVIEW_REF}" [remotes.preview.db.ssl_enforcement] enabled = true `); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + }), + ); // `legacyPresenceIn` reads `document` to detect optional pointer sections; // a remote-introduced `db.ssl_enforcement` must be present there. const db = loaded!.document?.db; expect(typeof db === "object" && db !== null && "ssl_enforcement" in db).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("forces db.seed.enabled false when the matching remote omits it", async () => { - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "forces db.seed.enabled false when the matching remote omits it", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [db.seed] enabled = true @@ -1783,65 +1898,65 @@ project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = 5 `); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + }), + ); expect(loaded!.config.db.seed.enabled).toBe(false); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("preserves db.seed.enabled when the matching remote sets it", async () => { - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "preserves db.seed.enabled when the matching remote sets it", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.preview] project_id = "${PREVIEW_REF}" [remotes.preview.db.seed] enabled = true `); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + }), + ); expect(loaded!.config.db.seed.enabled).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolves env() on a lowercase-named variable, matching Go's case-agnostic matcher", async () => { - // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:11`) is - // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so - // `project_id = "env(project_id)"` resolves against a same-case env var - // in the Go CLI. This isn't specific to `project_id`; any string field - // goes through the same pre-decode walk. This case-agnostic matching is - // itself one of the four Go-viper-parity behaviors gated by - // `goViperCompat` — without it, the strict SCREAMING_SNAKE_CASE matcher - // wouldn't match this lowercase name at all. - const previous = process.env.project_id; - process.env.project_id = "lowercase-ref"; - const cwd = await writeTomlProject(`project_id = "env(project_id)"\n`); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { goViperCompat: true })); + }), + ); + live( + "resolves env() on a lowercase-named variable, matching Go's case-agnostic matcher", + Effect.gen(function* () { + // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:11`) is + // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so + // `project_id = "env(project_id)"` resolves against a same-case env var + // in the Go CLI. This isn't specific to `project_id`; any string field + // goes through the same pre-decode walk. This case-agnostic matching is + // itself one of the four Go-viper-parity behaviors gated by + // `goViperCompat` — without it, the strict SCREAMING_SNAKE_CASE matcher + // wouldn't match this lowercase name at all. + const cwd = yield* writeTomlProject(`project_id = "env(project_id)"\n`); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + goViperCompat: true, + projectEnv: injectedProjectEnv({ + project_id: "lowercase-ref", + }), + }), + ); expect(loaded!.config.project_id).toBe("lowercase-ref"); - } finally { - if (previous === undefined) { - delete process.env.project_id; - } else { - process.env.project_id = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("does not match a remote whose project_id is env(REF) against the resolved ref (Go parity)", async () => { - // Go's `loadFromFile` duplicate-check/selection loop reads viper's RAW - // string values (`config.go:596-610`) and only calls `c.load(v)` — which - // resolves `env(...)` via `LoadEnvHook` — afterward (`config.go:611`, - // `decode_hooks.go:13-26`). So a `[remotes.x] project_id = "env(REF)"` - // never matches a caller-supplied, already-resolved `REF`: Go compares the - // literal `env(REF)` string, not what it resolves to. - const previous = process.env.SUPABASE_REMOTE_ENV_REF_TEST; - process.env.SUPABASE_REMOTE_ENV_REF_TEST = PREVIEW_REF; - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "does not match a remote whose project_id is env(REF) against the resolved ref (Go parity)", + Effect.gen(function* () { + // Go's `loadFromFile` duplicate-check/selection loop reads viper's RAW + // string values (`config.go:596-610`) and only calls `c.load(v)` — which + // resolves `env(...)` via `LoadEnvHook` — afterward (`config.go:611`, + // `decode_hooks.go:13-26`). So a `[remotes.x] project_id = "env(REF)"` + // never matches a caller-supplied, already-resolved `REF`: Go compares the + // literal `env(REF)` string, not what it resolves to. + const cwd = yield* writeTomlProject(`project_id = "baseref" [api] max_rows = 1 @@ -1851,51 +1966,46 @@ project_id = "env(SUPABASE_REMOTE_ENV_REF_TEST)" [remotes.preview.api] max_rows = 999 `); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + projectEnv: injectedProjectEnv({ + SUPABASE_REMOTE_ENV_REF_TEST: PREVIEW_REF, + }), + }), + ); expect(loaded!.appliedRemote).toBeUndefined(); expect(loaded!.config.api.max_rows).toBe(1); - } finally { - if (previous === undefined) { - delete process.env.SUPABASE_REMOTE_ENV_REF_TEST; - } else { - process.env.SUPABASE_REMOTE_ENV_REF_TEST = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("validates a remote's env(REF) project_id format against its resolved value, not the literal", async () => { - // Go's `Config.Validate` (`config.go:989-1001`) runs entirely after the - // struct decode, by which point `LoadEnvHook` has already resolved - // `env(...)` — so it validates the RESOLVED project_id against the - // 20-lowercase-letter pattern, not the literal `env(REF)` string (which - // would never match the pattern itself). - const previous = process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; - process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = PREVIEW_REF; - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "validates a remote's env(REF) project_id format against its resolved value, not the literal", + Effect.gen(function* () { + // Go's `Config.Validate` (`config.go:989-1001`) runs entirely after the + // struct decode, by which point `LoadEnvHook` has already resolved + // `env(...)` — so it validates the RESOLVED project_id against the + // 20-lowercase-letter pattern, not the literal `env(REF)` string (which + // would never match the pattern itself). + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.preview] project_id = "env(SUPABASE_REMOTE_ENV_REF_FORMAT_TEST)" `); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectEnv: injectedProjectEnv({ + SUPABASE_REMOTE_ENV_REF_FORMAT_TEST: PREVIEW_REF, + }), + }), + ); expect(loaded!.appliedRemote).toBeUndefined(); expect(loaded!.config.project_id).toBe("baseref"); - } finally { - if (previous === undefined) { - delete process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; - } else { - process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolves env() references inside the matching remote before merge", async () => { - const previous = process.env.SUPABASE_REMOTE_MAX_ROWS_TEST; - process.env.SUPABASE_REMOTE_MAX_ROWS_TEST = "777"; - const cwd = await writeTomlProject(`project_id = "baseref" + }), + ); + live( + "resolves env() references inside the matching remote before merge", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [api] max_rows = 1 @@ -1905,18 +2015,17 @@ project_id = "${PREVIEW_REF}" [remotes.preview.api] max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" `); - try { - const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); + const loaded = yield* runConfigProgram( + loadProjectConfig(cwd, { + projectRef: PREVIEW_REF, + projectEnv: injectedProjectEnv({ + SUPABASE_REMOTE_MAX_ROWS_TEST: "777", + }), + }), + ); expect(loaded!.config.api.max_rows).toBe(777); - } finally { - if (previous === undefined) { - delete process.env.SUPABASE_REMOTE_MAX_ROWS_TEST; - } else { - process.env.SUPABASE_REMOTE_MAX_ROWS_TEST = previous; - } - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); // Go's `Config.Validate` only checks `remotes.*.project_id` format for // every remote (`config.go:996-1001`, "Since remote config is merged to @@ -1925,79 +2034,72 @@ max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" // against the merged effective config (`config.go:1136-1152`), never // iterated over `c.Remotes[*]`. A non-selected `[remotes.*]` block's own // business-rule violations must not fail the whole config load. - test("loads an unselected remote whose external provider is enabled without a secret", async () => { - const cwd = await writeTomlProject( - `project_id = "baseref" + live( + "loads an unselected remote whose external provider is enabled without a secret", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.staging] project_id = "${STAGING_REF}" [remotes.staging.auth.external.github] enabled = true -`, - ); - try { +`); // No projectRef requested, so [remotes.staging] is never selected/merged — // Go would never business-rule-validate it, even though it decodes fine // structurally. - const loaded = await runConfigEffect(loadProjectConfig(cwd)); + const loaded = yield* runConfigProgram(loadProjectConfig(cwd)); expect(loaded!.appliedRemote).toBeUndefined(); expect(loaded!.config.remotes.staging?.auth.external.github.enabled).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("still validates the same remote's external provider once it is selected", async () => { - const cwd = await writeTomlProject( - `project_id = "baseref" + }), + ); + live( + "still validates the same remote's external provider once it is selected", + Effect.gen(function* () { + const cwd = yield* writeTomlProject(`project_id = "baseref" [remotes.staging] project_id = "${STAGING_REF}" [remotes.staging.auth.external.github] enabled = true -`, - ); - try { +`); // Selecting [remotes.staging] merges it into the effective config, which // Go DOES business-rule-validate (config.go:1136-1152) — a required // `client_id`/`secret` is missing, so this must still fail. - const exit = await Effect.runPromiseExit( - loadProjectConfig(cwd, { projectRef: STAGING_REF }).pipe(Effect.provide(BunServices.layer)), + const exit = yield* Effect.exit( + loadProjectConfig(cwd, { + projectRef: STAGING_REF, + }).pipe(Effect.provide(BunServices.layer)), ); expect(Exit.isFailure(exit)).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("still fails on a structurally malformed value inside an unselected remote", async () => { - // Go's `UnmarshalExact` always structurally decodes every remote - // (`config.go:246,749-756`) regardless of selection — only the - // merged-config-only business rules are skipped for a non-selected - // remote, not type/shape decoding. - const cwd = await writeTomlProject( - `${BASE_WITH_REMOTES} + }), + ); + live( + "still fails on a structurally malformed value inside an unselected remote", + Effect.gen(function* () { + // Go's `UnmarshalExact` always structurally decodes every remote + // (`config.go:246,749-756`) regardless of selection — only the + // merged-config-only business rules are skipped for a non-selected + // remote, not type/shape decoding. + const cwd = yield* writeTomlProject(`${BASE_WITH_REMOTES} [remotes.staging.db] major_version = "not-a-number" -`, - ); - try { - const exit = await Effect.runPromiseExit( +`); + const exit = yield* Effect.exit( loadProjectConfig(cwd).pipe(Effect.provide(BunServices.layer)), ); expect(Exit.isFailure(exit)).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); }); - describe("config io deprecated [inbucket] back-compat", () => { let warnings: Array<string> = []; - let errorSpy: ReturnType<typeof vi.spyOn> | undefined; - + let errorSpy: + | { + mockRestore: () => void; + } + | undefined; function captureWarnings() { warnings = []; // loadProjectConfigFile emits the deprecation warning via Console.error, whose @@ -2006,68 +2108,69 @@ describe("config io deprecated [inbucket] back-compat", () => { warnings.push(args.map((a) => String(a)).join(" ")); }); } - afterEach(() => { errorSpy?.mockRestore(); errorSpy = undefined; }); - - async function loadToml(contents: string) { - const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(path, contents); - try { - return await runConfigEffect(loadProjectConfigFile(path)); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + function loadToml(contents: string) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const configPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(configPath, contents); + return yield* runConfigProgram(loadProjectConfigFile(configPath)); + }); } - - test("loads a deprecated [inbucket] section as [local_smtp]", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + live( + "loads a deprecated [inbucket] section as [local_smtp]", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [inbucket] enabled = true port = 12345 -`, - ); - - expect(loaded.config.local_smtp.enabled).toBe(true); - expect(loaded.config.local_smtp.port).toBe(12345); - expect("inbucket" in loaded.config).toBe(false); - expect(loaded.document).not.toHaveProperty("inbucket"); - expect(loaded.document).toHaveProperty("local_smtp"); - expect( - warnings.some((m) => - m.includes( - "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", +`); + expect(loaded.config.local_smtp.enabled).toBe(true); + expect(loaded.config.local_smtp.port).toBe(12345); + expect("inbucket" in loaded.config).toBe(false); + expect(loaded.document).not.toHaveProperty("inbucket"); + expect(loaded.document).toHaveProperty("local_smtp"); + expect( + warnings.some((m) => + m.includes( + "WARN: config section [inbucket] is deprecated. Please use [local_smtp] instead.", + ), ), - ), - ).toBe(true); - }); - - test("fills schema defaults when a deprecated [inbucket] section is partial", async () => { - const loaded = await loadToml( - `project_id = "abc123" + ).toBe(true); + }), + ); + live( + "fills schema defaults when a deprecated [inbucket] section is partial", + Effect.gen(function* () { + const loaded = yield* loadToml(`project_id = "abc123" [inbucket] port = 9999 -`, - ); - - // enabled is omitted by the user; the schema default (true) must survive the - // inbucket -> local_smtp rewrite rather than collapsing to a zero value. - expect(loaded.config.local_smtp.enabled).toBe(true); - expect(loaded.config.local_smtp.port).toBe(9999); - }); +`); - test("prefers an explicit [local_smtp] when both sections are present", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + // enabled is omitted by the user; the schema default (true) must survive the + // inbucket -> local_smtp rewrite rather than collapsing to a zero value. + expect(loaded.config.local_smtp.enabled).toBe(true); + expect(loaded.config.local_smtp.port).toBe(9999); + }), + ); + live( + "prefers an explicit [local_smtp] when both sections are present", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [inbucket] enabled = true @@ -2076,19 +2179,18 @@ port = 11111 [local_smtp] enabled = true port = 22222 -`, - ); - - expect(loaded.config.local_smtp.port).toBe(22222); - expect(loaded.document).not.toHaveProperty("inbucket"); - // The deprecation warning still fires because the deprecated key was present. - expect(warnings.some((m) => m.includes("[inbucket] is deprecated"))).toBe(true); - }); - - test("normalizes a deprecated [remotes.*.inbucket] section", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" +`); + expect(loaded.config.local_smtp.port).toBe(22222); + expect(loaded.document).not.toHaveProperty("inbucket"); + // The deprecation warning still fires because the deprecated key was present. + expect(warnings.some((m) => m.includes("[inbucket] is deprecated"))).toBe(true); + }), + ); + live( + "normalizes a deprecated [remotes.*.inbucket] section", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [remotes.staging] project_id = "stagingrefaaaaaaaaaa" @@ -2096,154 +2198,160 @@ project_id = "stagingrefaaaaaaaaaa" [remotes.staging.inbucket] enabled = true port = 33333 -`, - ); - - const staging = loaded.config.remotes.staging; - expect(staging?.local_smtp?.port).toBe(33333); - expect(staging).not.toHaveProperty("inbucket"); - expect( - warnings.some((m) => - m.includes( - "WARN: config section [remotes.staging.inbucket] is deprecated. Please use [remotes.staging.local_smtp] instead.", +`); + const staging = loaded.config.remotes.staging; + expect(staging?.local_smtp?.port).toBe(33333); + expect(staging).not.toHaveProperty("inbucket"); + expect( + warnings.some((m) => + m.includes( + "WARN: config section [remotes.staging.inbucket] is deprecated. Please use [remotes.staging.local_smtp] instead.", + ), ), - ), - ).toBe(true); - }); - - test("does not warn when only [local_smtp] is used", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + ).toBe(true); + }), + ); + live( + "does not warn when only [local_smtp] is used", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [local_smtp] enabled = true port = 54324 -`, - ); - - expect(loaded.config.local_smtp.port).toBe(54324); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); +`); + expect(loaded.config.local_smtp.port).toBe(54324); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }), + ); }); - describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () => { let warnings: Array<string> = []; - let errorSpy: ReturnType<typeof vi.spyOn> | undefined; - + let errorSpy: + | { + mockRestore: () => void; + } + | undefined; function captureWarnings() { warnings = []; errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { warnings.push(args.map((a) => String(a)).join(" ")); }); } - afterEach(() => { errorSpy?.mockRestore(); errorSpy = undefined; }); - - async function loadToml(contents: string, options?: LoadProjectConfigOptions) { - const cwd = makeTempProject(); - const path = await runConfigEffect(configTomlPath(cwd)); - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(path, contents); - try { - return await runConfigEffect(loadProjectConfigFile(path, options)); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + function loadToml(contents: string, options?: LoadProjectConfigOptions) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "supabase-config-", + }); + const configPath = yield* runConfigProgram(configTomlPath(cwd)); + yield* fileSystem.makeDirectory(pathService.join(cwd, "supabase"), { + recursive: true, + }); + yield* fileSystem.writeFileString(configPath, contents); + return yield* runConfigProgram(loadProjectConfigFile(configPath, options)); + }); } - - test("loads a bare [auth.external.slack] block without required fields", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + live( + "loads a bare [auth.external.slack] block without required fields", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml( + `project_id = "abc123" [auth.external.slack] enabled = true `, - { goViperCompat: true }, - ); - - expect("slack" in loaded.config.auth.external).toBe(false); - expect(loaded.document).not.toHaveProperty("auth.external.slack"); - expect( - warnings.some((m) => - m.includes( - 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', + { + goViperCompat: true, + }, + ); + expect("slack" in loaded.config.auth.external).toBe(false); + expect(loaded.document).not.toHaveProperty("auth.external.slack"); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "slack" provider. Please use [auth.external.slack_oidc] instead', + ), ), - ), - ).toBe(true); - }); - - test("loads a bare [auth.external.linkedin] block without required fields", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + ).toBe(true); + }), + ); + live( + "loads a bare [auth.external.linkedin] block without required fields", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml( + `project_id = "abc123" [auth.external.linkedin] enabled = true `, - { goViperCompat: true }, - ); - - expect("linkedin" in loaded.config.auth.external).toBe(false); - expect( - warnings.some((m) => - m.includes( - 'WARN: disabling deprecated "linkedin" provider. Please use [auth.external.linkedin_oidc] instead', + { + goViperCompat: true, + }, + ); + expect("linkedin" in loaded.config.auth.external).toBe(false); + expect( + warnings.some((m) => + m.includes( + 'WARN: disabling deprecated "linkedin" provider. Please use [auth.external.linkedin_oidc] instead', + ), ), - ), - ).toBe(true); - }); - - test("does not warn when the deprecated section is present but disabled", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" + ).toBe(true); + }), + ); + live( + "does not warn when the deprecated section is present but disabled", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [auth.external.slack] enabled = false -`, - ); - - expect("slack" in loaded.config.auth.external).toBe(false); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); - - test("does not warn when only [auth.external.slack_oidc] is used", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" +`); + expect("slack" in loaded.config.auth.external).toBe(false); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }), + ); + live( + "does not warn when only [auth.external.slack_oidc] is used", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [auth.external.slack_oidc] enabled = true client_id = "abc" secret = "shh" -`, - ); - - expect(loaded.config.auth.external.slack_oidc.enabled).toBe(true); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); - - test("strips a deprecated [remotes.*.auth.external.slack] block without warning for an unselected remote", async () => { - captureWarnings(); - const loaded = await loadToml( - `project_id = "abc123" +`); + expect(loaded.config.auth.external.slack_oidc.enabled).toBe(true); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }), + ); + live( + "strips a deprecated [remotes.*.auth.external.slack] block without warning for an unselected remote", + Effect.gen(function* () { + captureWarnings(); + const loaded = yield* loadToml(`project_id = "abc123" [remotes.staging] project_id = "stagingrefaaaaaaaaaa" [remotes.staging.auth.external.slack] enabled = true -`, - ); +`); - // Not requesting `projectRef` means no remote is selected, so `remotes` survives - // decode verbatim (minus the deprecated key) rather than being merged/dropped. - expect(loaded.config.remotes.staging?.auth.external).not.toHaveProperty("slack"); - expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); - }); + // Not requesting `projectRef` means no remote is selected, so `remotes` survives + // decode verbatim (minus the deprecated key) rather than being merged/dropped. + expect(loaded.config.remotes.staging?.auth.external).not.toHaveProperty("slack"); + expect(warnings.some((m) => m.includes("is deprecated"))).toBe(false); + }), + ); }); diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index b90bf35619..d7286455c7 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -50,7 +50,7 @@ export const secret = (annotations?: SecretAnnotations) => // --------------------------------------------------------------------------- // // TOML/JSON parsers turn `port = "env(SUPABASE_ANALYTICS_PORT)"` into a string -// at `analytics.port`, but the schema declares `port: Schema.Number`. Without +// at `analytics.port`, but the schema declares `port: Schema.Finite`. Without // pre-decode handling the strict decoder rejects the string and crashes // `supabase db start` (CLI-1489). // @@ -124,7 +124,7 @@ function leafExpectedType(ast: SchemaAST.AST): ExpectedType { return isHomogeneousStringArray(node) ? "array" : "unknown"; case "Union": { // Walk Union branches in declared order; first concrete primitive wins. - // For unions like `Schema.Union(Schema.Number, Schema.Null)` this picks + // For unions like `Schema.Union(Schema.Finite, Schema.Null)` this picks // the meaningful side. If the union mixes Number and String we err on // the side of the first match — the schema decode will still validate // membership after coercion. diff --git a/packages/config/src/node.ts b/packages/config/src/node.ts index b2c68d416a..f205c9632a 100644 --- a/packages/config/src/node.ts +++ b/packages/config/src/node.ts @@ -1,5 +1,5 @@ import { NodeServices } from "@effect/platform-node"; -import { Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import type { LoadedProjectConfig, LoadProjectConfigOptions, @@ -22,7 +22,7 @@ function makeRuntime() { ); } -export async function loadProjectConfig( +export function loadProjectConfig( cwd: string, options?: LoadProjectConfigOptions, ): Promise<LoadedProjectConfig | null> { @@ -30,22 +30,22 @@ export async function loadProjectConfig( return runtime.runPromise(ProjectConfigStore.use((store) => store.load(cwd, options))); } -export async function findProjectRootFor(cwd: string): Promise<string | null> { +export function findProjectRootFor(cwd: string): Promise<string | null> { const runtime = makeRuntime(); return runtime.runPromise(findProjectRoot(cwd)); } -export async function findProjectPathsFor(cwd: string): Promise<ProjectPaths | null> { +export function findProjectPathsFor(cwd: string): Promise<ProjectPaths | null> { const runtime = makeRuntime(); return runtime.runPromise(findProjectPaths(cwd)); } -export async function loadProjectConfigFile(path: string): Promise<LoadedProjectConfig> { +export function loadProjectConfigFile(path: string): Promise<LoadedProjectConfig> { const runtime = makeRuntime(); return runtime.runPromise(ProjectConfigStore.use((store) => store.loadFile(path))); } -export async function loadProjectEnvironmentFor( +export function loadProjectEnvironmentFor( options: LoadProjectEnvironmentOptions, ): Promise<ProjectEnvironment | null> { const runtime = makeRuntime(); @@ -54,14 +54,20 @@ export async function loadProjectEnvironmentFor( ); } -export async function saveProjectConfig( - options: SaveProjectConfigOptions, -): Promise<LoadedProjectConfig> { +export function saveProjectConfig(options: SaveProjectConfigOptions): Promise<LoadedProjectConfig> { const runtime = makeRuntime(); return runtime.runPromise(ProjectConfigStore.use((store) => store.save(options))); } -export async function loadFunctionsManifest(cwd: string): Promise<FunctionsManifest> { +export function loadFunctionsManifest(cwd: string): Promise<FunctionsManifest> { const runtime = makeRuntime(); - return runtime.runPromise(inferFunctionsManifest({ cwd })); + return runtime.runPromise( + Effect.gen(function* () { + const projectEnv = yield* loadProjectEnvironment({ cwd, baseEnv: process.env }); + return yield* inferFunctionsManifest({ + cwd, + ...(projectEnv === null ? {} : { projectEnv }), + }); + }), + ); } diff --git a/packages/config/src/project-config.layer.ts b/packages/config/src/project-config.layer.ts index 7827080eb8..0966cf798e 100644 --- a/packages/config/src/project-config.layer.ts +++ b/packages/config/src/project-config.layer.ts @@ -1,6 +1,6 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { loadProjectConfig, loadProjectConfigFile, saveProjectConfig } from "./io.ts"; -import { ProjectConfigStore } from "./project-config.service.ts"; +import { ProjectConfigStore, ProjectConfigStoreError } from "./project-config.service.ts"; const makeProjectConfigStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -15,9 +15,18 @@ const makeProjectConfigStore = Effect.gen(function* () { ); return ProjectConfigStore.of({ - load: (cwd, options) => providePlatform(loadProjectConfig(cwd, options)), - loadFile: (filePath) => providePlatform(loadProjectConfigFile(filePath)), - save: (options) => providePlatform(saveProjectConfig(options)), + load: (cwd, options) => + providePlatform(loadProjectConfig(cwd, options)).pipe( + Effect.mapError((cause) => new ProjectConfigStoreError({ operation: "load", cause })), + ), + loadFile: (filePath) => + providePlatform(loadProjectConfigFile(filePath)).pipe( + Effect.mapError((cause) => new ProjectConfigStoreError({ operation: "loadFile", cause })), + ), + save: (options) => + providePlatform(saveProjectConfig(options)).pipe( + Effect.mapError((cause) => new ProjectConfigStoreError({ operation: "save", cause })), + ), }); }); diff --git a/packages/config/src/project-config.service.ts b/packages/config/src/project-config.service.ts index cc6906b28c..44026c0580 100644 --- a/packages/config/src/project-config.service.ts +++ b/packages/config/src/project-config.service.ts @@ -1,18 +1,24 @@ -import type { Effect } from "effect"; -import { Context } from "effect"; +import { Context, Data, type Effect } from "effect"; import type { LoadedProjectConfig, LoadProjectConfigOptions, SaveProjectConfigOptions, } from "./io.ts"; +export class ProjectConfigStoreError extends Data.TaggedError("ProjectConfigStoreError")<{ + readonly operation: "load" | "loadFile" | "save"; + readonly cause: unknown; +}> {} + interface ProjectConfigStoreShape { readonly load: ( cwd: string, options?: LoadProjectConfigOptions, - ) => Effect.Effect<LoadedProjectConfig | null, unknown>; - readonly loadFile: (path: string) => Effect.Effect<LoadedProjectConfig, unknown>; - readonly save: (options: SaveProjectConfigOptions) => Effect.Effect<LoadedProjectConfig, unknown>; + ) => Effect.Effect<LoadedProjectConfig | null, ProjectConfigStoreError>; + readonly loadFile: (path: string) => Effect.Effect<LoadedProjectConfig, ProjectConfigStoreError>; + readonly save: ( + options: SaveProjectConfigOptions, + ) => Effect.Effect<LoadedProjectConfig, ProjectConfigStoreError>; } export class ProjectConfigStore extends Context.Service< diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 28f4c5cd3a..655303645a 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -148,14 +148,14 @@ function parseDotEnv( const match = dotEnvLinePattern.exec(candidate); if (match === null) { - return yield* Effect.fail(new ProjectEnvParseError({ path, line: index + 1 })); + return yield* new ProjectEnvParseError({ path, line: index + 1 }); } const key = match[1]; const rawValue = match[2] ?? ""; if (key === undefined) { - return yield* Effect.fail(new ProjectEnvParseError({ path, line: index + 1 })); + return yield* new ProjectEnvParseError({ path, line: index + 1 }); } values[key] = parseDotEnvValue(rawValue); @@ -243,7 +243,8 @@ export const loadProjectEnvironment = Effect.fnUntraced(function* ( loadedPaths.push(paths.envLocalPath); } - applySource(values, sources, normalizeAmbientEnv(options.baseEnv), "ambient"); + const ambient = normalizeAmbientEnv(options.baseEnv); + applySource(values, sources, ambient, "ambient"); return { paths, diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index c96d5d29b5..bea5ca1fec 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -1,12 +1,10 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { mkdtempSync } from "node:fs"; -import { mkdir, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Effect, FileSystem, Path, Redacted } from "effect"; +import { Cause, Effect, Exit, FileSystem, Path, Redacted, Scope } from "effect"; import { findProjectRootFor, loadProjectEnvironmentFor } from "./bun.ts"; +import { loadProjectEnvironmentFor as loadProjectEnvironmentForNode } from "./node.ts"; import { ProjectConfigParseError, ProjectEnvParseError } from "./errors.ts"; +import { vi } from "vitest"; import { findProjectPaths, loadProjectConfig, @@ -14,138 +12,245 @@ import { resolveProjectSubtree, resolveProjectValue, } from "./index.ts"; - -function makeTempProject(): string { - return mkdtempSync(join(tmpdir(), "supabase-project-config-")); -} - -function runConfigEffect<A, E>( +function runConfigProgram<A, E>( effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>, -): Promise<A> { - return Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); +): Effect.Effect<A, E> { + return effect.pipe(Effect.provide(BunServices.layer)); } - +const live = <A, E>( + name: string, + effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>, +) => it.effect(name, () => effect.pipe(Effect.provide(BunServices.layer))); describe("project discovery and lazy env resolution", () => { - test("finds the nearest Supabase project upward", async () => { - const cwd = makeTempProject(); - const repoRoot = join(cwd, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const nestedCwd = join(packageRoot, "src", "components"); - - try { - await mkdir(join(repoRoot, "supabase"), { recursive: true }); - await mkdir(join(packageRoot, "supabase"), { recursive: true }); - await mkdir(nestedCwd, { recursive: true }); - await writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'); - await writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'); - - const paths = await runConfigEffect(findProjectPaths(nestedCwd)); - + live( + "does not read ambient environment when the core loader has no baseEnv", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-core-env-", + }); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, "supabase", "config.toml"), 'project_id = "test"\n'); + + const key = "SUPABASE_CONFIG_CORE_ENV_TEST"; + vi.stubEnv(key, "ambient-only"); + try { + const projectEnv = yield* runConfigProgram(loadProjectEnvironment({ cwd })); + expect(projectEnv?.values[key]).toBeUndefined(); + expect(projectEnv?.sources[key]).toBeUndefined(); + } finally { + vi.unstubAllEnvs(); + } + }), + ); + + live( + "preserves an explicitly empty process environment value in Bun and Node facades", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-empty-env-", + }); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, "supabase", "config.toml"), 'project_id = "test"\n'); + const key = "SUPABASE_CONFIG_EMPTY_ENV_TEST"; + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), `${key}=from-file\n`); + + vi.stubEnv(key, ""); + try { + const [fromBun, fromNode] = yield* Effect.all([ + Effect.promise(() => loadProjectEnvironmentFor({ cwd })), + Effect.promise(() => loadProjectEnvironmentForNode({ cwd })), + ]); + expect(fromBun?.values[key]).toBe(""); + expect(fromBun?.sources[key]).toBe("ambient"); + expect(fromNode?.values[key]).toBe(""); + expect(fromNode?.sources[key]).toBe("ambient"); + } finally { + vi.unstubAllEnvs(); + } + }), + ); + + live( + "finds the nearest Supabase project upward", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const repoRoot = path.join(cwd, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const nestedCwd = path.join(packageRoot, "src", "components"); + yield* fs.makeDirectory(path.join(repoRoot, "supabase"), { + recursive: true, + }); + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { + recursive: true, + }); + yield* fs.makeDirectory(nestedCwd, { + recursive: true, + }); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', + ); + const paths = yield* runConfigProgram(findProjectPaths(nestedCwd)); expect(paths?.projectRoot).toBe(packageRoot); - expect(paths?.supabaseDir).toBe(join(packageRoot, "supabase")); - expect(paths?.configPath).toBe(join(packageRoot, "supabase", "config.toml")); - expect(await findProjectRootFor(nestedCwd)).toBe(packageRoot); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("search: false only checks cwd itself, matching Go's exact-workdir resolution", async () => { - // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:238-257`): - // an explicit workdir is used exactly as given, with no ancestor climb — - // callers that already hold a Go-equivalent project root (e.g. the legacy - // `stop`/`status` ports' `cliConfig.workdir`) pass `search: false` to avoid - // picking up an unrelated ancestor project. - const cwd = makeTempProject(); - const repoRoot = join(cwd, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const nestedCwd = join(packageRoot, "src", "components"); - - try { - await mkdir(join(repoRoot, "supabase"), { recursive: true }); - await mkdir(nestedCwd, { recursive: true }); - await writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'); + expect(paths?.supabaseDir).toBe(path.join(packageRoot, "supabase")); + expect(paths?.configPath).toBe(path.join(packageRoot, "supabase", "config.toml")); + expect(yield* Effect.promise(() => findProjectRootFor(nestedCwd))).toBe(packageRoot); + }), + ); + live( + "search: false only checks cwd itself, matching Go's exact-workdir resolution", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:238-257`): + // an explicit workdir is used exactly as given, with no ancestor climb — + // callers that already hold a Go-equivalent project root (e.g. the legacy + // `stop`/`status` ports' `cliConfig.workdir`) pass `search: false` to avoid + // picking up an unrelated ancestor project. + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const repoRoot = path.join(cwd, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const nestedCwd = path.join(packageRoot, "src", "components"); + yield* fs.makeDirectory(path.join(repoRoot, "supabase"), { + recursive: true, + }); + yield* fs.makeDirectory(nestedCwd, { + recursive: true, + }); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); // nestedCwd has no supabase/ of its own; only an ancestor (repoRoot) does. - const searched = await runConfigEffect(findProjectPaths(nestedCwd)); + const searched = yield* runConfigProgram(findProjectPaths(nestedCwd)); expect(searched?.projectRoot).toBe(repoRoot); - - const unsearched = await runConfigEffect(findProjectPaths(nestedCwd, { search: false })); + const unsearched = yield* runConfigProgram( + findProjectPaths(nestedCwd, { + search: false, + }), + ); expect(unsearched).toBeNull(); - - const configAtRepoRoot = await runConfigEffect(findProjectPaths(repoRoot, { search: false })); + const configAtRepoRoot = yield* runConfigProgram( + findProjectPaths(repoRoot, { + search: false, + }), + ); expect(configAtRepoRoot?.projectRoot).toBe(repoRoot); - - expect(await runConfigEffect(loadProjectConfig(nestedCwd, { search: false }))).toBeNull(); expect( - await runConfigEffect(loadProjectEnvironment({ cwd: nestedCwd, search: false })), + yield* runConfigProgram( + loadProjectConfig(nestedCwd, { + search: false, + }), + ), ).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("climbs past a FILE named `supabase` in the starting directory instead of failing with ENOTDIR", async () => { - // Go's getProjectRoot keeps climbing on any stat error - // (apps/cli-go/internal/utils/misc.go:216-231) — a stray FILE named - // `supabase` (not a directory) must read as "no config here", not crash. - const cwd = makeTempProject(); - const nestedCwd = join(cwd, "child"); - - try { - await mkdir(nestedCwd, { recursive: true }); - await writeFile(join(nestedCwd, "supabase"), "not a directory\n"); - - const paths = await runConfigEffect(findProjectPaths(nestedCwd)); - + expect( + yield* runConfigProgram( + loadProjectEnvironment({ + cwd: nestedCwd, + search: false, + }), + ), + ).toBeNull(); + }), + ); + live( + "climbs past a FILE named `supabase` in the starting directory instead of failing with ENOTDIR", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Go's getProjectRoot keeps climbing on any stat error + // (apps/cli-go/internal/utils/misc.go:216-231) — a stray FILE named + // `supabase` (not a directory) must read as "no config here", not crash. + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const nestedCwd = path.join(cwd, "child"); + yield* fs.makeDirectory(nestedCwd, { + recursive: true, + }); + yield* fs.writeFileString(path.join(nestedCwd, "supabase"), "not a directory\n"); + const paths = yield* runConfigProgram(findProjectPaths(nestedCwd)); expect(paths).toBeNull(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("returns the parent's project when the starting directory has a FILE named `supabase` but the parent has a real config", async () => { - const cwd = makeTempProject(); - const child = join(cwd, "child"); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await mkdir(child, { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "parent"\n'); - await writeFile(join(child, "supabase"), "not a directory\n"); - - const paths = await runConfigEffect(findProjectPaths(child)); - + }), + ); + live( + "returns the parent's project when the starting directory has a FILE named `supabase` but the parent has a real config", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const child = path.join(cwd, "child"); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { + recursive: true, + }); + yield* fs.makeDirectory(child, { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "parent"\n', + ); + yield* fs.writeFileString(path.join(child, "supabase"), "not a directory\n"); + const paths = yield* runConfigProgram(findProjectPaths(child)); expect(paths?.projectRoot).toBe(cwd); - expect(paths?.configPath).toBe(join(cwd, "supabase", "config.toml")); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads env from the discovered supabase directory with the right precedence", async () => { - const cwd = makeTempProject(); - const repoRoot = join(cwd, "repo"); - const packageRoot = join(repoRoot, "apps", "web"); - const nestedCwd = join(packageRoot, "src"); - - try { - await mkdir(join(repoRoot, "supabase"), { recursive: true }); - await mkdir(join(packageRoot, "supabase"), { recursive: true }); - await mkdir(nestedCwd, { recursive: true }); - await writeFile(join(repoRoot, "supabase", "config.toml"), 'project_id = "repo"\n'); - await writeFile(join(repoRoot, "supabase", ".env"), "ROOT_ONLY=repo\n"); - await writeFile(join(packageRoot, "supabase", "config.toml"), 'project_id = "web"\n'); - await writeFile( - join(packageRoot, "supabase", ".env"), + expect(paths?.configPath).toBe(path.join(cwd, "supabase", "config.toml")); + }), + ); + live( + "loads env from the discovered supabase directory with the right precedence", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const repoRoot = path.join(cwd, "repo"); + const packageRoot = path.join(repoRoot, "apps", "web"); + const nestedCwd = path.join(packageRoot, "src"); + yield* fs.makeDirectory(path.join(repoRoot, "supabase"), { + recursive: true, + }); + yield* fs.makeDirectory(path.join(packageRoot, "supabase"), { + recursive: true, + }); + yield* fs.makeDirectory(nestedCwd, { + recursive: true, + }); + yield* fs.writeFileString( + path.join(repoRoot, "supabase", "config.toml"), + 'project_id = "repo"\n', + ); + yield* fs.writeFileString(path.join(repoRoot, "supabase", ".env"), "ROOT_ONLY=repo\n"); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", "config.toml"), + 'project_id = "web"\n', + ); + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env"), "SHARED_ONLY=from-env\nOVERRIDE_ME=from-env\n", ); - await writeFile( - join(packageRoot, "supabase", ".env.local"), + yield* fs.writeFileString( + path.join(packageRoot, "supabase", ".env.local"), "LOCAL_ONLY=from-local\nOVERRIDE_ME=from-local\n", ); - - const projectEnv = await runConfigEffect( + const projectEnv = yield* runConfigProgram( loadProjectEnvironment({ cwd: nestedCwd, baseEnv: { @@ -154,7 +259,6 @@ describe("project discovery and lazy env resolution", () => { }, }), ); - expect(projectEnv).not.toBeNull(); expect(projectEnv?.values.SHARED_ONLY).toBe("from-env"); expect(projectEnv?.values.LOCAL_ONLY).toBe("from-local"); @@ -163,31 +267,37 @@ describe("project discovery and lazy env resolution", () => { expect(projectEnv?.values.ROOT_ONLY).toBeUndefined(); expect(projectEnv?.sources.OVERRIDE_ME).toBe("ambient"); expect(projectEnv?.loadedPaths).toEqual([ - join(packageRoot, "supabase", ".env"), - join(packageRoot, "supabase", ".env.local"), + path.join(packageRoot, "supabase", ".env"), + path.join(packageRoot, "supabase", ".env.local"), ]); - - const fromBun = await loadProjectEnvironmentFor({ - cwd: nestedCwd, - baseEnv: { - OVERRIDE_ME: "from-ambient", - }, - }); - + const fromBun = yield* Effect.promise(() => + loadProjectEnvironmentFor({ + cwd: nestedCwd, + baseEnv: { + OVERRIDE_ME: "from-ambient", + }, + }), + ); expect(fromBun?.paths.projectRoot).toBe(packageRoot); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("parses a multiline double-quoted .env value (godotenv/Go parity)", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); - await writeFile( - join(cwd, "supabase", ".env"), + }), + ); + live( + "parses a multiline double-quoted .env value (godotenv/Go parity)", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "ref_123"\n', + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", ".env"), [ 'PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----', "MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aumga", @@ -196,9 +306,11 @@ describe("project discovery and lazy env resolution", () => { "", ].join("\n"), ); - - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); - + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd, + }), + ); expect(projectEnv).not.toBeNull(); expect(projectEnv?.values.PRIVATE_KEY).toBe( [ @@ -208,116 +320,156 @@ describe("project discovery and lazy env resolution", () => { ].join("\n"), ); expect(projectEnv?.values.OTHER).toBe("value"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("parses a multiline single-quoted .env value followed by a trailing comment", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); - await writeFile( - join(cwd, "supabase", ".env"), + }), + ); + live( + "parses a multiline single-quoted .env value followed by a trailing comment", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "ref_123"\n', + ); + yield* fs.writeFileString( + path.join(cwd, "supabase", ".env"), ["MULTI='line one", "line two' # trailing comment", "AFTER=ok", ""].join("\n"), ); - - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd })); - + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd, + }), + ); expect(projectEnv).not.toBeNull(); expect(projectEnv?.values.MULTI).toBe(["line one", "line two"].join("\n")); expect(projectEnv?.values.AFTER).toBe("ok"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("still fails a genuinely malformed .env line (not a multiline quote)", async () => { - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); - await writeFile(join(cwd, "supabase", ".env"), "!!!not-a-valid-line\n"); - - await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( - ProjectEnvParseError, + }), + ); + live( + "still fails a genuinely malformed .env line (not a multiline quote)", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "ref_123"\n', ); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("skipEnvLocal ignores .env.local entirely, matching Go's SUPABASE_ENV=test gate", async () => { - // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) omits - // `.env.local` from its candidate filename list whenever `SUPABASE_ENV=test`, - // so a malformed `.env.local` is invisible to Go in that mode. Callers that - // reproduce this gate (`status`/`stop` handlers) pass `skipEnvLocal: true`. - const cwd = makeTempProject(); - - try { - await mkdir(join(cwd, "supabase"), { recursive: true }); - await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "ref_123"\n'); - await writeFile(join(cwd, "supabase", ".env"), "FROM_ENV=1\n"); + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), "!!!not-a-valid-line\n"); + const failure = yield* runConfigProgram( + loadProjectEnvironment({ + cwd, + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(failure)).toBe(true); + if (Exit.isFailure(failure)) { + expect(Cause.squash(failure.cause)).toBeInstanceOf(ProjectEnvParseError); + } + }), + ); + live( + "skipEnvLocal ignores .env.local entirely, matching Go's SUPABASE_ENV=test gate", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) omits + // `.env.local` from its candidate filename list whenever `SUPABASE_ENV=test`, + // so a malformed `.env.local` is invisible to Go in that mode. Callers that + // reproduce this gate (`status`/`stop` handlers) pass `skipEnvLocal: true`. + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + yield* fs.makeDirectory(path.join(cwd, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(cwd, "supabase", "config.toml"), + 'project_id = "ref_123"\n', + ); + yield* fs.writeFileString(path.join(cwd, "supabase", ".env"), "FROM_ENV=1\n"); // Malformed — would normally throw ProjectEnvParseError. - await writeFile(join(cwd, "supabase", ".env.local"), "!!!not-a-valid-line\n"); - - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd, skipEnvLocal: true })); - + yield* fs.writeFileString(path.join(cwd, "supabase", ".env.local"), "!!!not-a-valid-line\n"); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd, + skipEnvLocal: true, + }), + ); expect(projectEnv).not.toBeNull(); expect(projectEnv?.values.FROM_ENV).toBe("1"); - expect(projectEnv?.loadedPaths).toEqual([join(cwd, "supabase", ".env")]); + expect(projectEnv?.loadedPaths).toEqual([path.join(cwd, "supabase", ".env")]); // Without the flag, the same malformed file still fails as before. - await expect(runConfigEffect(loadProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( - ProjectEnvParseError, + const failure = yield* runConfigProgram( + loadProjectEnvironment({ + cwd, + }), + ).pipe(Effect.exit); + expect(Exit.isFailure(failure)).toBe(true); + if (Exit.isFailure(failure)) { + expect(Cause.squash(failure.cause)).toBeInstanceOf(ProjectEnvParseError); + } + }), + ); + live( + "leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), + `project_id = "ref_123"\n`, ); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("leaves [api].auto_expose_new_tables unset by default and round-trips an explicit value", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile(join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123"\n`); - - const defaultLoaded = await runConfigEffect(loadProjectConfig(projectRoot)); + const defaultLoaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); // Field is intentionally optional today so the implicit default can flip on 2026-05-30 // without losing track of users who explicitly opted in either direction. expect(defaultLoaded!.config.api.auto_expose_new_tables).toBeUndefined(); - - await writeFile( - join(projectRoot, "supabase", "config.toml"), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123"\n\n[api]\nauto_expose_new_tables = false\n`, ); - const explicitFalse = await runConfigEffect(loadProjectConfig(projectRoot)); + const explicitFalse = yield* runConfigProgram(loadProjectConfig(projectRoot)); expect(explicitFalse!.config.api.auto_expose_new_tables).toBe(false); - - await writeFile( - join(projectRoot, "supabase", "config.toml"), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123"\n\n[api]\nauto_expose_new_tables = true\n`, ); - const explicitTrue = await runConfigEffect(loadProjectConfig(projectRoot)); + const explicitTrue = yield* runConfigProgram(loadProjectConfig(projectRoot)); expect(explicitTrue!.config.api.auto_expose_new_tables).toBe(true); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("loads raw config without resolving explicit env() references", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "loads raw config without resolving explicit env() references", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth] @@ -328,59 +480,71 @@ enabled = false auth_token = "env(TWILIO_AUTH_TOKEN)" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); expect(loaded!.config.auth.jwt_secret).toBe("env(AUTH_JWT_SECRET)"); expect(loaded!.config.auth.sms.twilio.auth_token).toBe("env(TWILIO_AUTH_TOKEN)"); expect(projectEnv?.values.AUTH_JWT_SECRET).toBeUndefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolveProjectValue resolves explicit env() and redacts secret leaves", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "resolveProjectValue resolves explicit env() and redacts secret leaves", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth] jwt_secret = "env(AUTH_JWT_SECRET)" `, ); - await writeFile(join(projectRoot, "supabase", ".env"), "AUTH_JWT_SECRET=super-secret\n"); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const resolved = await runConfigEffect( + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), + "AUTH_JWT_SECRET=super-secret\n", + ); + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const resolved = yield* runConfigProgram( resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), ); - expect(Redacted.isRedacted(resolved)).toBe(true); if (!Redacted.isRedacted(resolved)) { throw new Error("Expected auth.jwt_secret to be redacted."); } expect(Redacted.value(resolved)).toBe("super-secret"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolveProjectSubtree resolves nested records and remotes lazily", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "resolveProjectSubtree resolves nested records and remotes lazily", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [edge_runtime.secrets] @@ -393,58 +557,63 @@ project_id = "previewrefaaaaaaaaaa" jwt_secret = "env(PREVIEW_JWT_SECRET)" `, ); - await writeFile( - join(projectRoot, "supabase", ".env"), + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), "EDGE_API_KEY=edge-secret\nPREVIEW_JWT_SECRET=preview-secret\n", ); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const edgeRuntime = await runConfigEffect( + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const edgeRuntime = yield* runConfigProgram( resolveProjectSubtree(loaded!.config.edge_runtime, projectEnv!, "edge_runtime"), ); - const previewRemote = await runConfigEffect( + const previewRemote = yield* runConfigProgram( resolveProjectSubtree(loaded!.config.remotes.preview, projectEnv!, "remotes.preview"), ); - const edgeSecret = edgeRuntime.secrets?.api_key; expect(Redacted.isRedacted(edgeSecret)).toBe(true); if (!Redacted.isRedacted(edgeSecret)) { throw new Error("Expected edge_runtime.secrets.api_key to be redacted."); } expect(Redacted.value(edgeSecret)).toBe("edge-secret"); - const previewSecret = previewRemote!.auth.jwt_secret; expect(Redacted.isRedacted(previewSecret)).toBe(true); if (!Redacted.isRedacted(previewSecret)) { throw new Error("Expected remotes.preview.auth.jwt_secret to be redacted."); } expect(Redacted.value(previewSecret)).toBe("preview-secret"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolveProjectValue preserves env() literal when the env var is missing (Go parity)", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "resolveProjectValue preserves env() literal when the env var is missing (Go parity)", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth] jwt_secret = "env(MISSING_SECRET)" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const resolved = await runConfigEffect( + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const resolved = yield* runConfigProgram( resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), ); @@ -452,57 +621,65 @@ jwt_secret = "env(MISSING_SECRET)" // through as plain strings so callers can see the missing reference. expect(Redacted.isRedacted(resolved)).toBe(false); expect(resolved).toBe("env(MISSING_SECRET)"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:19-24`) only // substitutes a non-empty env var (`len(env) > 0`) — a present-but-empty // dotenv line (`EMPTY_SECRET=`) is treated the same as an unset var, so the // literal `env(...)` reference is preserved rather than resolved to `""`. - test("resolveProjectValue preserves env() literal when the env var is present but empty (Go parity)", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + live( + "resolveProjectValue preserves env() literal when the env var is present but empty (Go parity)", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [edge_runtime.secrets] foo = "env(EMPTY_SECRET)" `, ); - await writeFile(join(projectRoot, "supabase", ".env"), "EMPTY_SECRET=\n"); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const resolved = await runConfigEffect( + yield* fs.writeFileString(path.join(projectRoot, "supabase", ".env"), "EMPTY_SECRET=\n"); + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const resolved = yield* runConfigProgram( resolveProjectValue( loaded!.config.edge_runtime.secrets!.foo, projectEnv!, "edge_runtime.secrets.foo", ), ); - expect(Redacted.isRedacted(resolved)).toBe(false); expect(resolved).toBe("env(EMPTY_SECRET)"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolveProjectSubtree preserves env() literals nested inside the selected subtree", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "resolveProjectSubtree preserves env() literals nested inside the selected subtree", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth.sms.twilio] @@ -510,28 +687,32 @@ enabled = false auth_token = "env(MISSING_SECRET)" `, ); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const resolved = await runConfigEffect( + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const resolved = yield* runConfigProgram( resolveProjectSubtree(loaded!.config.auth.sms.twilio, projectEnv!, "auth.sms.twilio"), ); - expect(resolved.auth_token).toBe("env(MISSING_SECRET)"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("raw config validation still enforces enabled feature requirements", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "raw config validation still enforces enabled feature requirements", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth.sms.twilio] @@ -539,83 +720,97 @@ enabled = true account_sid = "AC123" `, ); - - await expect(runConfigEffect(loadProjectConfig(projectRoot))).rejects.toBeInstanceOf( - ProjectConfigParseError, - ); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + const failure = yield* runConfigProgram(loadProjectConfig(projectRoot)).pipe(Effect.exit); + expect(Exit.isFailure(failure)).toBe(true); + if (Exit.isFailure(failure)) { + expect(Cause.squash(failure.cause)).toBeInstanceOf(ProjectConfigParseError); + } + }), + ); // Pins the pre-PR-#5765 strict SCREAMING_SNAKE_CASE `env()` matcher as the // default for `resolveProjectValue`/`resolveProjectSubtree`, since `next/` // and `packages/stack` call these without ever passing `goViperCompat`. - test("resolveProjectValue does not resolve a lowercase-named env() reference by default", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + live( + "resolveProjectValue does not resolve a lowercase-named env() reference by default", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth] jwt_secret = "env(lowercase_secret)" `, ); - await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const resolved = await runConfigEffect( + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), + "lowercase_secret=super-secret\n", + ); + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const resolved = yield* runConfigProgram( resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), ); - expect(Redacted.isRedacted(resolved)).toBe(true); if (!Redacted.isRedacted(resolved)) { throw new Error("Expected auth.jwt_secret to be redacted."); } expect(Redacted.value(resolved)).toBe("env(lowercase_secret)"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); - - test("resolveProjectValue resolves a lowercase-named env() reference when goViperCompat is true", async () => { - const cwd = makeTempProject(); - const projectRoot = join(cwd, "repo"); - - try { - await mkdir(join(projectRoot, "supabase"), { recursive: true }); - await writeFile( - join(projectRoot, "supabase", "config.toml"), + }), + ); + live( + "resolveProjectValue resolves a lowercase-named env() reference when goViperCompat is true", + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectoryScoped({ + prefix: "supabase-project-config-", + }); + const projectRoot = path.join(cwd, "repo"); + yield* fs.makeDirectory(path.join(projectRoot, "supabase"), { + recursive: true, + }); + yield* fs.writeFileString( + path.join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123" [auth] jwt_secret = "env(lowercase_secret)" `, ); - await writeFile(join(projectRoot, "supabase", ".env"), "lowercase_secret=super-secret\n"); - - const loaded = await runConfigEffect(loadProjectConfig(projectRoot)); - const projectEnv = await runConfigEffect(loadProjectEnvironment({ cwd: projectRoot })); - - const resolved = await runConfigEffect( + yield* fs.writeFileString( + path.join(projectRoot, "supabase", ".env"), + "lowercase_secret=super-secret\n", + ); + const loaded = yield* runConfigProgram(loadProjectConfig(projectRoot)); + const projectEnv = yield* runConfigProgram( + loadProjectEnvironment({ + cwd: projectRoot, + }), + ); + const resolved = yield* runConfigProgram( resolveProjectValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret", { goViperCompat: true, }), ); - expect(Redacted.isRedacted(resolved)).toBe(true); if (!Redacted.isRedacted(resolved)) { throw new Error("Expected auth.jwt_secret to be redacted."); } expect(Redacted.value(resolved)).toBe("super-secret"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); + }), + ); }); diff --git a/packages/config/src/realtime.ts b/packages/config/src/realtime.ts index 10299ad7fa..ff11cfc30c 100644 --- a/packages/config/src/realtime.ts +++ b/packages/config/src/realtime.ts @@ -32,7 +32,7 @@ export const realtime = Schema.Struct({ }, ], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultIpVersion))), - max_header_length: Schema.Number.annotate({ + max_header_length: Schema.Finite.annotate({ default: defaultMaxHeaderLength, description: "Maximum length of the HTTP header.", tags, diff --git a/packages/config/src/storage.ts b/packages/config/src/storage.ts index 67d6ec0c06..4009315407 100644 --- a/packages/config/src/storage.ts +++ b/packages/config/src/storage.ts @@ -39,7 +39,7 @@ const defaultVectorBuckets = {}; * numeric value is normalized to its decimal string so the decoded type stays a * `string` for all consumers (`ramInBytes` parses either form identically). */ -const fileSizeLimit = Schema.Union([Schema.String, Schema.Number]).pipe( +const fileSizeLimit = Schema.Union([Schema.String, Schema.Finite]).pipe( Schema.decodeTo(Schema.String, { decode: SchemaGetter.transform((value) => (typeof value === "number" ? String(value) : value)), encode: SchemaGetter.transform((value) => value), @@ -126,19 +126,19 @@ export const storage = Schema.Struct({ tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultAnalyticsEnabled))), - max_namespaces: Schema.Number.annotate({ + max_namespaces: Schema.Finite.annotate({ default: defaultMaxNamespaces, description: "Maximum number of analytics namespaces.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxNamespaces))), - max_tables: Schema.Number.annotate({ + max_tables: Schema.Finite.annotate({ default: defaultMaxTables, description: "Maximum number of analytics tables.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxTables))), - max_catalogs: Schema.Number.annotate({ + max_catalogs: Schema.Finite.annotate({ default: defaultMaxCatalogs, description: "Maximum number of analytics catalogs.", tags, @@ -164,13 +164,13 @@ export const storage = Schema.Struct({ tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultVectorEnabled))), - max_buckets: Schema.Number.annotate({ + max_buckets: Schema.Finite.annotate({ default: defaultMaxBuckets, description: "Maximum number of vector buckets.", tags, links, }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultMaxBuckets))), - max_indexes: Schema.Number.annotate({ + max_indexes: Schema.Finite.annotate({ default: defaultMaxIndexes, description: "Maximum number of vector indexes.", tags, diff --git a/packages/config/src/studio.ts b/packages/config/src/studio.ts index 16fc90ec0d..876411d7fb 100644 --- a/packages/config/src/studio.ts +++ b/packages/config/src/studio.ts @@ -25,7 +25,7 @@ export const studio = Schema.Struct({ tags, links: [links.studio], }).pipe(Schema.withDecodingDefaultKey(Effect.succeed(defaultEnabled))), - port: Schema.Number.annotate({ + port: Schema.Finite.annotate({ default: defaultPort, description: "Port to use for Supabase Studio.", tags, diff --git a/packages/process-compose/package.json b/packages/process-compose/package.json index c192fa8190..3df91f4711 100644 --- a/packages/process-compose/package.json +++ b/packages/process-compose/package.json @@ -13,10 +13,10 @@ "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, "dependencies": { + "@effect/platform-bun": "catalog:", "effect": "catalog:" }, "devDependencies": { - "@effect/platform-bun": "catalog:", "@effect/vitest": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", diff --git a/packages/process-compose/src/ChildSignal.integration.test.ts b/packages/process-compose/src/ChildSignal.integration.test.ts new file mode 100644 index 0000000000..a9d36e7771 --- /dev/null +++ b/packages/process-compose/src/ChildSignal.integration.test.ts @@ -0,0 +1,29 @@ +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { childSignalFromCause } from "./ChildSignal.ts"; + +describe("childSignalFromCause", () => { + it.live("decodes the signal from an actual child-process exit cause", () => + Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ); + yield* child.kill({ killSignal: "SIGTERM" }); + const result = yield* child.exitCode.pipe(Effect.exit); + + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Option.getOrUndefined(childSignalFromCause(result.cause))).toBe("SIGTERM"); + } + }), + ).pipe(Effect.provide(BunServices.layer)), + ); +}); diff --git a/packages/process-compose/src/ChildSignal.ts b/packages/process-compose/src/ChildSignal.ts new file mode 100644 index 0000000000..a7f739e139 --- /dev/null +++ b/packages/process-compose/src/ChildSignal.ts @@ -0,0 +1,69 @@ +import { Cause, Option } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; + +const signalFrom = (value: unknown): ChildProcess.Signal | undefined => { + switch (value) { + case "SIGABRT": + case "SIGALRM": + case "SIGBUS": + case "SIGCHLD": + case "SIGCONT": + case "SIGFPE": + case "SIGHUP": + case "SIGILL": + case "SIGINT": + case "SIGIO": + case "SIGIOT": + case "SIGKILL": + case "SIGPIPE": + case "SIGPOLL": + case "SIGPROF": + case "SIGPWR": + case "SIGQUIT": + case "SIGSEGV": + case "SIGSTKFLT": + case "SIGSTOP": + case "SIGSYS": + case "SIGTERM": + case "SIGTRAP": + case "SIGTSTP": + case "SIGTTIN": + case "SIGTTOU": + case "SIGUNUSED": + case "SIGURG": + case "SIGUSR1": + case "SIGUSR2": + case "SIGVTALRM": + case "SIGWINCH": + case "SIGXCPU": + case "SIGXFSZ": + case "SIGBREAK": + case "SIGLOST": + case "SIGINFO": + return value; + default: + return undefined; + } +}; + +/** Extracts the signal reported by Effect's child-process exit failure. */ +export const childSignalFromCause = ( + cause: Cause.Cause<PlatformError.PlatformError>, +): Option.Option<ChildProcess.Signal> => + Option.flatMap(Cause.findErrorOption(cause), (error) => { + if ( + !(error instanceof PlatformError.PlatformError) || + !(error.reason instanceof PlatformError.SystemError) || + error.reason.method !== "exitCode" || + !(error.reason.cause instanceof Error) + ) { + return Option.none(); + } + + const match = /^Process interrupted due to receipt of signal: '([^']+)'$/.exec( + error.reason.cause.message, + ); + const signal = signalFrom(match?.[1]); + return signal === undefined ? Option.none() : Option.some(signal); + }); diff --git a/packages/process-compose/src/DependencyGraph.ts b/packages/process-compose/src/DependencyGraph.ts index 04aad61d97..d36ac7de63 100644 --- a/packages/process-compose/src/DependencyGraph.ts +++ b/packages/process-compose/src/DependencyGraph.ts @@ -65,7 +65,7 @@ export const buildGraph = ( }); if (missingDepError !== undefined) { - yield* Effect.fail(missingDepError); + return yield* missingDepError; } // Check for cycles before calling topo (which would throw a generic GraphError) @@ -75,7 +75,7 @@ export const buildGraph = ( for (const [, svc] of Graph.nodes(graph)) { cycleNodes.push(svc.name); } - yield* Effect.fail(new CyclicDependencyError({ cycle: cycleNodes.join(" -> ") })); + return yield* new CyclicDependencyError({ cycle: cycleNodes.join(" -> ") }); } // Compute start order via topological sort (yields dependencies first) diff --git a/packages/process-compose/src/HealthProbe.ts b/packages/process-compose/src/HealthProbe.ts index 179dfb9d8b..3cfcef817d 100644 --- a/packages/process-compose/src/HealthProbe.ts +++ b/packages/process-compose/src/HealthProbe.ts @@ -1,24 +1,23 @@ import * as Net from "node:net"; import { Duration, Effect, Match, Ref, Schedule } from "effect"; +import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { defaults, type HealthCheckConfig, type ProbeConfig } from "./ServiceDef.ts"; const executeProbe = ( probe: ProbeConfig, timeoutSeconds: number, -): Effect.Effect<boolean, never, ChildProcessSpawner.ChildProcessSpawner> => { +): Effect.Effect< + boolean, + never, + ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient +> => { return Match.valueTags(probe, { Http: (probe) => - Effect.tryPromise({ - try: (signal) => - fetch(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`, { - signal, - }), - catch: (cause) => cause, - }).pipe( + HttpClient.get(`${probe.scheme}://${probe.host}:${probe.port}${probe.path}`).pipe( Effect.timeout(Duration.seconds(timeoutSeconds)), - Effect.map((res) => res.ok), - Effect.catch(() => Effect.succeed(false)), + Effect.map((res) => res.status >= 200 && res.status < 300), + Effect.orElseSucceed(() => false), ), Exec: (probe) => { const cmd = ChildProcess.make(probe.command, probe.args, { @@ -31,7 +30,7 @@ const executeProbe = ( Effect.timeout(Duration.seconds(timeoutSeconds)), Effect.map((opt) => opt ?? false), ), - ).pipe(Effect.catch(() => Effect.succeed(false))); + ).pipe(Effect.orElseSucceed(() => false)); }, Tcp: (probe) => Effect.callback<boolean>((resume) => { @@ -48,21 +47,21 @@ const executeProbe = ( }).pipe( Effect.timeout(Duration.seconds(timeoutSeconds)), Effect.map((opt) => opt ?? false), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ), }); }; export interface HealthProbeCallbacks { - readonly onHealthy: () => Effect.Effect<void>; - readonly onUnhealthy: () => Effect.Effect<void>; + readonly onHealthy: Effect.Effect<void>; + readonly onUnhealthy: Effect.Effect<void>; } export const runHealthProbe = (config: { readonly name: string; readonly healthCheck: HealthCheckConfig; readonly callbacks: HealthProbeCallbacks; -}): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner> => +}): Effect.Effect<void, never, ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient> => Effect.gen(function* () { const hc = config.healthCheck; const initialDelay = hc.initialDelaySeconds ?? defaults.healthCheck.initialDelaySeconds; @@ -92,7 +91,7 @@ export const runHealthProbe = (config: { if (phase !== "Healthy" && successes + 1 >= successThreshold) { phase = "Healthy"; hasEverBeenHealthy = true; - yield* config.callbacks.onHealthy(); + yield* config.callbacks.onHealthy; } } else { const { failures } = yield* Ref.getAndUpdate(counters, (c) => ({ @@ -104,7 +103,7 @@ export const runHealthProbe = (config: { : startupFailureThreshold; if (phase !== "Unhealthy" && failures + 1 >= activeFailureThreshold) { phase = "Unhealthy"; - yield* config.callbacks.onUnhealthy(); + yield* config.callbacks.onUnhealthy; } } }), diff --git a/packages/process-compose/src/HealthProbe.unit.test.ts b/packages/process-compose/src/HealthProbe.unit.test.ts index 452ec998c6..a723d1640e 100644 --- a/packages/process-compose/src/HealthProbe.unit.test.ts +++ b/packages/process-compose/src/HealthProbe.unit.test.ts @@ -1,44 +1,60 @@ -import { mkdtempSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; import * as Net from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; -import { Deferred, Duration, Effect, Exit, Fiber, Layer, Predicate, Sink, Stream } from "effect"; +import { + Deferred, + Duration, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Path, + Predicate, + Sink, + Stream, +} from "effect"; +import { FetchHttpClient, HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { runHealthProbe } from "./HealthProbe.ts"; import type { HealthCheckConfig, ProbeConfig } from "./ServiceDef.ts"; -const platformLayer = BunChildProcessSpawnerLayer.pipe( - Layer.provide(Layer.mergeAll(BunFileSystemLayer, BunPathLayer)), +const platformLayer = Layer.mergeAll( + BunChildProcessSpawnerLayer.pipe(Layer.provide(Layer.mergeAll(BunFileSystemLayer, BunPathLayer))), + BunFileSystemLayer, + BunPathLayer, + FetchHttpClient.layer, ); const sequenceProbeLayer = (results: ReadonlyArray<boolean>) => { let calls = 0; return { - layer: Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => - Effect.sync(() => { - const result = results[calls] ?? results.at(-1) ?? false; - calls++; - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(2000 + calls), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result ? 0 : 1)), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), + layer: Layer.mergeAll( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.sync(() => { + const result = results[calls] ?? results.at(-1) ?? false; + calls++; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(2000 + calls), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result ? 0 : 1)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ), ), + FetchHttpClient.layer, ), get calls() { return calls; @@ -63,16 +79,14 @@ const setupProbe = (probe: ProbeConfig, overrides?: Partial<HealthCheckConfig>) ...overrides, }, callbacks: { - onHealthy: () => - Effect.gen(function* () { - healthy = true; - yield* Deferred.succeed(healthySignal, void 0); - }), - onUnhealthy: () => - Effect.gen(function* () { - healthy = false; - yield* Deferred.succeed(unhealthySignal, void 0); - }), + onHealthy: Effect.gen(function* () { + healthy = true; + yield* Deferred.succeed(healthySignal, void 0); + }), + onUnhealthy: Effect.gen(function* () { + healthy = false; + yield* Deferred.succeed(unhealthySignal, void 0); + }), }, }; return { healthySignal, unhealthySignal, config, isHealthy: () => healthy }; @@ -80,17 +94,25 @@ const setupProbe = (probe: ProbeConfig, overrides?: Partial<HealthCheckConfig>) describe("HealthProbe", () => { it.live("aborts an in-flight HTTP probe when its fiber is interrupted", () => { - const originalFetch = globalThis.fetch; return Effect.gen(function* () { const started = yield* Deferred.make<void>(); let aborted = false; - globalThis.fetch = ((_input: RequestInfo | URL, init?: RequestInit) => { - init?.signal?.addEventListener("abort", () => { - aborted = true; - }); - Effect.runSync(Deferred.succeed(started, void 0)); - return new Promise<Response>(() => undefined); - }) as typeof fetch; + const client = HttpClient.make((_request, _url, signal) => + Effect.gen(function* () { + yield* Deferred.succeed(started, void 0); + return yield* Effect.callback<never>((_resume, callbackSignal) => { + const onAbort = () => { + aborted = true; + }; + callbackSignal.addEventListener("abort", onAbort, { once: true }); + signal.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => { + callbackSignal.removeEventListener("abort", onAbort); + signal.removeEventListener("abort", onAbort); + }); + }); + }), + ); const { config } = yield* setupProbe({ _tag: "Http", @@ -99,15 +121,15 @@ describe("HealthProbe", () => { port: 80, path: "/health", }); - const fiber = yield* Effect.forkChild(runHealthProbe(config), { startImmediately: true }); + const fiber = yield* runHealthProbe(config).pipe( + Effect.provideService(HttpClient.HttpClient, client), + Effect.forkChild({ startImmediately: true }), + ); yield* Deferred.await(started); yield* Fiber.interrupt(fiber); expect(aborted).toBe(true); - }).pipe( - Effect.ensuring(Effect.sync(() => (globalThis.fetch = originalFetch))), - Effect.provide(platformLayer), - ); + }).pipe(Effect.provide(platformLayer)); }); it.live("Exec probes require explicit args", () => @@ -156,32 +178,35 @@ describe("HealthProbe", () => { readonly command: string; readonly args: ReadonlyArray<string>; }> = []; - const layer = Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => - Effect.sync(() => { - if (Predicate.isTagged(command, "StandardCommand")) { - spawned.push({ - command: command.command, - args: command.args, + const layer = Layer.mergeAll( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (Predicate.isTagged(command, "StandardCommand")) { + spawned.push({ + command: command.command, + args: command.args, + }); + } + + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1234), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + stdin: Sink.drain, + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, }); - } - - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1234), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - stdin: Sink.drain, - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), + }), + ), ), + FetchHttpClient.layer, ); return Effect.gen(function* () { @@ -326,17 +351,15 @@ describe("HealthProbe", () => { failureThreshold: 2, }, callbacks: { - onHealthy: () => - Effect.sync(() => { - healthyTransitions++; - }), - onUnhealthy: () => - Effect.gen(function* () { - unhealthyTransitions++; - if (unhealthyTransitions === 2) { - yield* Deferred.succeed(secondUnhealthy, void 0); - } - }), + onHealthy: Effect.sync(() => { + healthyTransitions++; + }), + onUnhealthy: Effect.gen(function* () { + unhealthyTransitions++; + if (unhealthyTransitions === 2) { + yield* Deferred.succeed(secondUnhealthy, void 0); + } + }), }, }), ); @@ -447,11 +470,13 @@ describe("HealthProbe", () => { it.live("transitions to Unhealthy after failureThreshold failures following Healthy", () => Effect.gen(function* () { - const tempDir = mkdtempSync(join(tmpdir(), "health-probe-test-")); - const flagFile = join(tempDir, "healthy"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "health-probe-test-" }); + const flagFile = path.join(tempDir, "healthy"); // Create the flag file so probe succeeds initially - writeFileSync(flagFile, ""); + yield* fs.writeFileString(flagFile, ""); const { healthySignal, unhealthySignal, config, isHealthy } = yield* setupProbe( { _tag: "Exec", command: "test", args: ["-f", flagFile] }, @@ -464,17 +489,12 @@ describe("HealthProbe", () => { expect(isHealthy()).toBe(true); // Remove the flag file so probe starts failing - try { - unlinkSync(flagFile); - } catch { - /* ignore */ - } + yield* fs.remove(flagFile, { force: true }).pipe(Effect.ignore); yield* Deferred.await(unhealthySignal).pipe(Effect.timeout(Duration.seconds(5))); expect(isHealthy()).toBe(false); yield* Fiber.interrupt(fiber); - rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(platformLayer)), ); }); diff --git a/packages/process-compose/src/LogBuffer.ts b/packages/process-compose/src/LogBuffer.ts index 673f00fb05..b75059ccd6 100644 --- a/packages/process-compose/src/LogBuffer.ts +++ b/packages/process-compose/src/LogBuffer.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer, PubSub, Ref, Semaphore, Stream } from "effect"; +import { Clock, Context, Effect, Layer, PubSub, Ref, Semaphore, Stream } from "effect"; export interface LogEntry { readonly timestamp: number; @@ -18,7 +18,7 @@ export class LogBuffer extends Context.Service< line: string, ) => Effect.Effect<void>; readonly subscribe: (service: string) => Stream.Stream<LogEntry>; - readonly subscribeAll: () => Stream.Stream<LogEntry>; + readonly subscribeAll: Stream.Stream<LogEntry>; readonly history: (service: string, limit?: number) => Effect.Effect<ReadonlyArray<LogEntry>>; readonly historyAll: ( limit?: number, @@ -48,13 +48,13 @@ export class LogBuffer extends Context.Service< pubsub: servicePubSubs.get(service)!, buffer: serviceBuffers.get(service)!, }; - }).pipe(serviceInitialization.withPermit); + }).pipe(Semaphore.withPermit(serviceInitialization)); return { append: (service, stream, line) => Effect.gen(function* () { const entry: LogEntry = { - timestamp: Date.now(), + timestamp: yield* Clock.currentTimeMillis, service, stream, line, @@ -80,7 +80,7 @@ export class LogBuffer extends Context.Service< }), ), - subscribeAll: () => Stream.fromPubSub(globalPubSub), + subscribeAll: Stream.fromPubSub(globalPubSub), history: (service, limit = 100) => Effect.gen(function* () { diff --git a/packages/process-compose/src/LogBuffer.unit.test.ts b/packages/process-compose/src/LogBuffer.unit.test.ts index 4822364862..dee6de6547 100644 --- a/packages/process-compose/src/LogBuffer.unit.test.ts +++ b/packages/process-compose/src/LogBuffer.unit.test.ts @@ -71,7 +71,7 @@ describe("LogBuffer", () => { const log = yield* LogBuffer; // Collect 3 entries from the global subscription - const collectEffect = log.subscribeAll().pipe(Stream.take(3), Stream.runCollect); + const collectEffect = log.subscribeAll.pipe(Stream.take(3), Stream.runCollect); const fiber = yield* Effect.forkChild(collectEffect, { startImmediately: true }); yield* log.append("svcA", "stdout", "from-a"); diff --git a/packages/process-compose/src/Orchestrator.integration.test.ts b/packages/process-compose/src/Orchestrator.integration.test.ts index 8990f6657e..06609812c7 100644 --- a/packages/process-compose/src/Orchestrator.integration.test.ts +++ b/packages/process-compose/src/Orchestrator.integration.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; -import { Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { Clock, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"; +import { FetchHttpClient } from "effect/unstable/http"; import { buildGraph } from "./DependencyGraph.ts"; import { LogBuffer } from "./LogBuffer.ts"; import { Orchestrator } from "./Orchestrator.ts"; @@ -13,7 +14,7 @@ const spawnerLayer = BunChildProcessSpawnerLayer.pipe( Layer.provide(Layer.mergeAll(BunFileSystemLayer, BunPathLayer)), ); -const deps = Layer.mergeAll(spawnerLayer, LogBuffer.layer); +const deps = Layer.mergeAll(spawnerLayer, LogBuffer.layer, FetchHttpClient.layer); function setupReal(defs: ReadonlyArray<ServiceDef>) { const graph = Effect.runSync(buildGraph(defs)); @@ -31,8 +32,8 @@ const fileExistsProbe = (path: string) => }) satisfies ProbeConfig; type StateReader = { - readonly getAllStates: () => Effect.Effect<ReadonlyArray<ServiceState>>; - readonly allStateChanges: () => Stream.Stream<ServiceState>; + readonly getAllStates: Effect.Effect<ReadonlyArray<ServiceState>>; + readonly allStateChanges: Stream.Stream<ServiceState>; }; const waitForStatuses = ( @@ -43,7 +44,7 @@ const waitForStatuses = ( }>, ): Effect.Effect<void> => Effect.gen(function* () { - const current = yield* orc.getAllStates(); + const current = yield* orc.getAllStates; const matches = (states: ReadonlyArray<ServiceState>) => predicates.every(({ name, predicate }) => { const state = states.find((candidate) => candidate.name === name); @@ -51,7 +52,7 @@ const waitForStatuses = ( }); if (matches(current)) return; - yield* orc.allStateChanges().pipe( + yield* orc.allStateChanges.pipe( Stream.scan(new Map(current.map((state) => [state.name, state])), (states, state) => new Map(states).set(state.name, state), ), @@ -95,7 +96,7 @@ describe("Orchestrator integration", () => { ); const events: Array<string> = []; const startEntered = yield* Deferred.make<void>(); - const stop = orc.stop().pipe( + const stop = orc.stop.pipe( Effect.tap(() => Effect.sync(() => { events.push("stop"); @@ -122,7 +123,7 @@ describe("Orchestrator integration", () => { expect(Option.isSome(startResult)).toBe(true); expect(events).toEqual(["stop", "start"]); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -165,7 +166,7 @@ describe("Orchestrator integration", () => { expect(stateB.pid).toBeGreaterThan(0); expect(stateA.startedAt!).toBeLessThanOrEqual(stateB.startedAt!); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -174,7 +175,7 @@ describe("Orchestrator integration", () => { it.live( "health check transitions to Healthy with exec probe", () => { - const flagFile = `/tmp/pc-e2e-flag-${Date.now()}`; + const flagFile = `/tmp/pc-e2e-flag-${process.pid}`; const defs: ServiceDef[] = [ { @@ -205,7 +206,7 @@ describe("Orchestrator integration", () => { const state = yield* orc.getState("flag-service"); expect(state.status).toBe("Healthy"); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -235,7 +236,7 @@ describe("Orchestrator integration", () => { expect(a.pid).toBeGreaterThan(0); expect(b.pid).toBeGreaterThan(0); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -262,9 +263,9 @@ describe("Orchestrator integration", () => { { name: "sleep-c", predicate: (state) => isUp(state.status) }, ]); - const before = Date.now(); - yield* orc.stop(); - const elapsed = Date.now() - before; + const before = yield* Clock.currentTimeMillis; + yield* orc.stop; + const elapsed = (yield* Clock.currentTimeMillis) - before; // 3 services * 2s timeout each = 6s sequential. // sleep responds to SIGTERM quickly, so parallel should be < 2s. @@ -304,7 +305,7 @@ describe("Orchestrator integration", () => { expect(lines).toContain("line-two"); expect(lines).toContain("line-three"); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -357,7 +358,7 @@ describe("resource cleanup", () => { expect(isPidAlive(pidA)).toBe(true); expect(isPidAlive(pidB)).toBe(true); - yield* orc.stop(); + yield* orc.stop; expect(isPidAlive(pidA)).toBe(false); expect(isPidAlive(pidB)).toBe(false); @@ -405,7 +406,7 @@ describe("resource cleanup", () => { expect(isPidAlive(pidTarget)).toBe(false); expect(isPidAlive(pidBystander)).toBe(true); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -441,7 +442,7 @@ describe("resource cleanup", () => { const state = yield* orc.getState("restartable"); expect(state.status).toBe("Stopped"); - yield* orc.stop(); + yield* orc.stop; }).pipe(Effect.provide(layer), Effect.scoped); }, { timeout: 15000 }, @@ -450,7 +451,7 @@ describe("resource cleanup", () => { it.live( "exec health probe processes cleaned up on stop", () => { - const flagFile = `/tmp/pc-cleanup-flag-${Date.now()}`; + const flagFile = `/tmp/pc-cleanup-flag-${process.pid}`; const defs: ServiceDef[] = [ { name: "probed", @@ -479,7 +480,7 @@ describe("resource cleanup", () => { ]); const pid = (yield* orc.getState("probed")).pid!; - yield* orc.stop(); + yield* orc.stop; expect(isPidAlive(pid)).toBe(false); }).pipe(Effect.provide(layer), Effect.scoped); diff --git a/packages/process-compose/src/Orchestrator.ts b/packages/process-compose/src/Orchestrator.ts index 70028c34fa..f6a12fedfd 100644 --- a/packages/process-compose/src/Orchestrator.ts +++ b/packages/process-compose/src/Orchestrator.ts @@ -1,6 +1,8 @@ import { Cause, + Clock, Deferred, + DateTime, Duration, Effect, Exit, @@ -11,11 +13,13 @@ import { Context, Option, Predicate, + PlatformError, Semaphore, Stream, SubscriptionRef, } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; import { buildGraph, type ResolvedGraph } from "./DependencyGraph.ts"; import { type HealthProbeCallbacks, runHealthProbe } from "./HealthProbe.ts"; import { LogBuffer } from "./LogBuffer.ts"; @@ -28,10 +32,17 @@ import type { ServiceStartOptions, } from "./ServiceDef.ts"; import { defaults } from "./ServiceDef.ts"; -import { initial, ServiceState, type ServiceDesiredState } from "./ServiceState.ts"; +import { + fields as serviceStateFields, + initial, + ServiceState, + type ServiceDesiredState, +} from "./ServiceState.ts"; import { makeSupervisedCommand, usesSupervisor } from "./Supervisor.ts"; import { CyclicDependencyError, + CleanupExecutionError, + HookExecutionError, MissingDependencyError, ServiceNotFoundError, ServiceReadyError, @@ -58,7 +69,7 @@ const willRestartAfterExit = (def: ServiceDef, state: ServiceState): boolean => // Some one-shot adapters report `isRunning: false` before their exit-code Effect is observable. // Keep the compensating poll isolated here so the ordinary process-exit path remains event-driven. const waitForProcessToStop = (handle: { - readonly isRunning: Effect.Effect<boolean, unknown, never>; + readonly isRunning: Effect.Effect<boolean, PlatformError.PlatformError, never>; }): Effect.Effect<void> => Effect.gen(function* () { let running = true; @@ -66,7 +77,7 @@ const waitForProcessToStop = (handle: { while: () => running, body: () => handle.isRunning.pipe( - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), Effect.tap((next) => Effect.sync(() => (running = next))), Effect.andThen(Effect.sleep(Duration.millis(100))), ), @@ -82,7 +93,7 @@ export class Orchestrator extends Context.Service< name: string, options?: ServiceStartOptions, ) => Effect.Effect<void, ServiceNotFoundError>; - readonly stop: () => Effect.Effect<void>; + readonly stop: Effect.Effect<void>; readonly stopService: (name: string) => Effect.Effect<void, ServiceNotFoundError>; readonly restartService: ( name: string, @@ -93,26 +104,31 @@ export class Orchestrator extends Context.Service< def: ServiceDef, ) => Effect.Effect<void, ServiceNotFoundError | CyclicDependencyError | MissingDependencyError>; readonly getState: (name: string) => Effect.Effect<ServiceState, ServiceNotFoundError>; - readonly getAllStates: () => Effect.Effect<ReadonlyArray<ServiceState>>; + readonly getAllStates: Effect.Effect<ReadonlyArray<ServiceState>>; readonly stateChanges: ( name: string, ) => Effect.Effect<Stream.Stream<ServiceState>, ServiceNotFoundError>; - readonly allStateChanges: () => Stream.Stream<ServiceState>; + readonly allStateChanges: Stream.Stream<ServiceState>; readonly waitReady: ( name: string, ) => Effect.Effect<void, ServiceNotFoundError | ServiceReadyError>; - readonly waitAllReady: () => Effect.Effect<void, ServiceReadyError>; + readonly waitAllReady: Effect.Effect<void, ServiceReadyError>; } >()("process-compose/Orchestrator") { static layer = ( initialGraph: ResolvedGraph, config?: OrchestratorConfig, - ): Layer.Layer<Orchestrator, never, ChildProcessSpawner.ChildProcessSpawner | LogBuffer> => + ): Layer.Layer< + Orchestrator, + never, + ChildProcessSpawner.ChildProcessSpawner | LogBuffer | HttpClient.HttpClient + > => Layer.effect( this, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const logBuffer = yield* LogBuffer; + const httpClient = yield* HttpClient.HttpClient; let graph = initialGraph; const appendRecentServiceLogs = ( @@ -130,7 +146,7 @@ export class Orchestrator extends Context.Service< } for (const entry of recentLogs) { - const ts = new Date(entry.timestamp).toISOString(); + const ts = DateTime.formatIso(DateTime.makeUnsafe(entry.timestamp)); yield* logBuffer.append(name, "stderr", ` | ${ts} ${entry.stream}: ${entry.line}`); } }); @@ -171,7 +187,9 @@ export class Orchestrator extends Context.Service< const svc = services.get(name); if (svc === undefined) return Effect.void; return SubscriptionRef.update(svc.state, (state) => - state.desired === desired ? state : new ServiceState({ ...state, desired }), + state.desired === desired + ? state + : new ServiceState({ ...serviceStateFields(state), desired }), ); }; @@ -194,9 +212,11 @@ export class Orchestrator extends Context.Service< const timeout = hook.timeoutSeconds ?? defaults.hookTimeoutSeconds; const log = (stream: "stdout" | "stderr", line: string) => logBuffer.append(def.name, stream, line); - const result = yield* hook - .run(log) - .pipe(Effect.timeout(Duration.seconds(timeout)), Effect.exit); + const result = yield* hook.run(log).pipe( + Effect.mapError((cause) => new HookExecutionError({ cause })), + Effect.timeout(Duration.seconds(timeout)), + Effect.exit, + ); if (Exit.isFailure(result) && (hook.failurePolicy ?? "fail") === "fail") { return `Hook (on:${trigger}) failed: ${Cause.pretty(result.cause)}`; } @@ -310,7 +330,7 @@ export class Orchestrator extends Context.Service< // Run a single spawn-and-wait cycle; returns exit code or unhealthy restart signal. // Caller must transition to Starting before calling this. - const spawnOnce = (): Effect.Effect<SpawnResult, SpawnError> => + const spawnOnce = (): Effect.Effect<SpawnResult, SpawnError, HttpClient.HttpClient> => Effect.scoped( Effect.gen(function* () { const generationResult = Deferred.makeUnsafe<SpawnResult>(); @@ -341,10 +361,7 @@ export class Orchestrator extends Context.Service< Effect.mapError((cause) => new SpawnError({ service: def.command, cause })), ); - const waitForHandleExit = handle.exitCode.pipe( - Effect.asVoid, - Effect.catch(() => Effect.void), - ); + const waitForHandleExit = handle.exitCode.pipe(Effect.asVoid, Effect.ignore); const sendSignal = (signal: ChildProcess.Signal): Effect.Effect<void> => handle @@ -357,6 +374,7 @@ export class Orchestrator extends Context.Service< def.cleanup == null ? Effect.void : def.cleanup.pipe( + Effect.mapError((cause) => new CleanupExecutionError({ cause })), Effect.catchCause((cause) => logBuffer.append( def.name, @@ -386,7 +404,7 @@ export class Orchestrator extends Context.Service< ), ), ), - Effect.catch(() => Effect.void), + Effect.ignore, Effect.andThen(runCleanup()), Effect.ensuring(Effect.sync(() => forceStops.delete(def.name))), ), @@ -401,64 +419,56 @@ export class Orchestrator extends Context.Service< yield* sendEvent(def.name, { _tag: "ProcessSpawned", pid: handle.pid, - startedAt: Date.now(), + startedAt: yield* Clock.currentTimeMillis, }); // Fork log streaming (stdout + stderr) — decode binary to text lines - yield* handle.stdout - .pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runForEach((line) => logBuffer.append(def.name, "stdout", line)), - ) - .pipe( - Effect.catch(() => Effect.void), - Effect.forkChild, - ); + yield* handle.stdout.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => logBuffer.append(def.name, "stdout", line)), + Effect.ignore, + Effect.forkChild, + ); - yield* handle.stderr - .pipe( - Stream.decodeText, - Stream.splitLines, - Stream.runForEach((line) => logBuffer.append(def.name, "stderr", line)), - ) - .pipe( - Effect.catch(() => Effect.void), - Effect.forkChild, - ); + yield* handle.stderr.pipe( + Stream.decodeText, + Stream.splitLines, + Stream.runForEach((line) => logBuffer.append(def.name, "stderr", line)), + Effect.ignore, + Effect.forkChild, + ); // Health checking if (def.healthCheck) { const callbacks: HealthProbeCallbacks = { - onHealthy: () => - Effect.gen(function* () { - const service = services.get(def.name); - if (service === undefined) return; - const current = SubscriptionRef.getUnsafe(service.state); - if (current.status === "Running" || current.status === "Unhealthy") { - const healthyHookError = yield* runHooks(def, "healthy"); - if (healthyHookError !== null) { - yield* Deferred.succeed(generationResult, { - _tag: "HookFailed", - error: healthyHookError, - }); - return; - } - } - yield* sendEvent(def.name, { _tag: "HealthCheckPassed" }); - }).pipe(Effect.asVoid), - onUnhealthy: () => - Effect.gen(function* () { - yield* sendEvent(def.name, { _tag: "HealthCheckFailed" }); - yield* appendRecentServiceLogs( - def.name, - `[health-check-failed] Service "${def.name}" became unhealthy. Recent output:`, - `[health-check-failed] Service "${def.name}" became unhealthy (no recent log output).`, - ); - if (restartPolicy !== "no") { - yield* Deferred.succeed(generationResult, { _tag: "Unhealthy" }); + onHealthy: Effect.gen(function* () { + const service = services.get(def.name); + if (service === undefined) return; + const current = SubscriptionRef.getUnsafe(service.state); + if (current.status === "Running" || current.status === "Unhealthy") { + const healthyHookError = yield* runHooks(def, "healthy"); + if (healthyHookError !== null) { + yield* Deferred.succeed(generationResult, { + _tag: "HookFailed", + error: healthyHookError, + }); + return; } - }), + } + yield* sendEvent(def.name, { _tag: "HealthCheckPassed" }); + }).pipe(Effect.asVoid), + onUnhealthy: Effect.gen(function* () { + yield* sendEvent(def.name, { _tag: "HealthCheckFailed" }); + yield* appendRecentServiceLogs( + def.name, + `[health-check-failed] Service "${def.name}" became unhealthy. Recent output:`, + `[health-check-failed] Service "${def.name}" became unhealthy (no recent log output).`, + ); + if (restartPolicy !== "no") { + yield* Deferred.succeed(generationResult, { _tag: "Unhealthy" }); + } + }), }; yield* runHealthProbe({ name: def.name, @@ -485,9 +495,10 @@ export class Orchestrator extends Context.Service< _tag: "ProcessExit", exitCode: Number(code), })), - Effect.catch((): Effect.Effect<SpawnResult> => - Effect.succeed({ _tag: "ProcessExit", exitCode: 143 }), - ), + Effect.orElseSucceed((): SpawnResult => ({ + _tag: "ProcessExit", + exitCode: 143, + })), ); const waitForObservedOneShotExit = restartPolicy === "no" && def.healthCheck == null @@ -495,9 +506,10 @@ export class Orchestrator extends Context.Service< Effect.andThen( waitForExit.pipe( Effect.timeout(Duration.millis(100)), - Effect.catch((): Effect.Effect<SpawnResult> => - Effect.succeed({ _tag: "ProcessExit", exitCode: 0 }), - ), + Effect.orElseSucceed((): SpawnResult => ({ + _tag: "ProcessExit", + exitCode: 0, + })), ), ), ) @@ -612,7 +624,7 @@ export class Orchestrator extends Context.Service< error: UNHEALTHY_RESTART_EXHAUSTED_ERROR, }); } - }); + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); const runServiceSafe = (def: ServiceDef, options?: ServiceStartOptions) => Effect.sync(() => { @@ -755,13 +767,13 @@ export class Orchestrator extends Context.Service< yield* setDesired(def.name, "running"); yield* FiberMap.run(fibers, def.name, runServiceSafe(def, options)); } - }).pipe(lifecycleLock.withPermit), + }).pipe(Semaphore.withPermit(lifecycleLock)), startService: (name: string, options) => Effect.gen(function* () { const def = lookupDef(name); if (def === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } const order = graph.startOrderFor(name); for (const d of order) { @@ -799,85 +811,84 @@ export class Orchestrator extends Context.Service< onlyIfMissing: true, }); } - }).pipe(lifecycleLock.withPermit), + }).pipe(Semaphore.withPermit(lifecycleLock)), - stop: () => - Effect.gen(function* () { - const timeoutSecs = config?.shutdownTimeoutSeconds ?? defaults.shutdownTimeoutSeconds; - const desiredBeforeStop = new Map( - graph.startOrder.map((def) => { - const svc = services.get(def.name); - return [ - def.name, - svc === undefined ? "inactive" : SubscriptionRef.getUnsafe(svc.state).desired, - ] as const; - }), - ); - - yield* Effect.forEach( - graph.startOrder.filter((def) => desiredBeforeStop.get(def.name) === "running"), - (def) => setDesired(def.name, "stopped"), - { discard: true }, - ); + stop: Effect.gen(function* () { + const timeoutSecs = config?.shutdownTimeoutSeconds ?? defaults.shutdownTimeoutSeconds; + const desiredBeforeStop = new Map( + graph.startOrder.map((def) => { + const svc = services.get(def.name); + return [ + def.name, + svc === undefined ? "inactive" : SubscriptionRef.getUnsafe(svc.state).desired, + ] as const; + }), + ); - const stopAll = Effect.gen(function* () { - const waitUntilStopped = (name: string) => { - const service = services.get(name); - return service === undefined - ? Effect.void - : waitForState( - service, - (state) => state.desired === "inactive" || state.status === "Stopped", - ).pipe(Effect.asVoid); - }; - const stopOne = (def: ServiceDef) => - Effect.gen(function* () { - if (desiredBeforeStop.get(def.name) === "inactive") { - return; - } - // Wait for all dependents to be stopped first - const dependents = graph.dependentsOf(def.name); - for (const dep of dependents) { - yield* waitUntilStopped(dep.name); - } + yield* Effect.forEach( + graph.startOrder.filter((def) => desiredBeforeStop.get(def.name) === "running"), + (def) => setDesired(def.name, "stopped"), + { discard: true }, + ); - // Now safe to stop this service - yield* sendEvent(def.name, { _tag: "StopRequested" }); - yield* FiberMap.remove(fibers, def.name); - // Force Stopped if still in Stopping (fiber was interrupted before ProcessExited) - yield* sendEvent(def.name, { _tag: "ProcessExited", exitCode: 143 }); - }); + const stopAll = Effect.gen(function* () { + const waitUntilStopped = (name: string) => { + const service = services.get(name); + return service === undefined + ? Effect.void + : waitForState( + service, + (state) => state.desired === "inactive" || state.status === "Stopped", + ).pipe(Effect.asVoid); + }; + const stopOne = (def: ServiceDef) => + Effect.gen(function* () { + if (desiredBeforeStop.get(def.name) === "inactive") { + return; + } + // Wait for all dependents to be stopped first + const dependents = graph.dependentsOf(def.name); + for (const dep of dependents) { + yield* waitUntilStopped(dep.name); + } - // Fork all stop effects in parallel - yield* Effect.all( - graph.startOrder.map((def) => stopOne(def)), - { concurrency: "unbounded" }, - ); - }); + // Now safe to stop this service + yield* sendEvent(def.name, { _tag: "StopRequested" }); + yield* FiberMap.remove(fibers, def.name); + // Force Stopped if still in Stopping (fiber was interrupted before ProcessExited) + yield* sendEvent(def.name, { _tag: "ProcessExited", exitCode: 143 }); + }); - const stopFiber = yield* Effect.forkChild(stopAll); - const stoppedInTime = yield* Effect.race( - Fiber.await(stopFiber).pipe(Effect.as(true)), - Effect.sleep(Duration.seconds(timeoutSecs)).pipe(Effect.as(false)), + // Fork all stop effects in parallel + yield* Effect.all( + graph.startOrder.map((def) => stopOne(def)), + { concurrency: "unbounded" }, ); + }); - if (!stoppedInTime) { - for (const def of graph.startOrder) { - yield* logBuffer.append( - def.name, - "stderr", - `[shutdown-timeout] Global shutdown timed out after ${timeoutSecs}s, force-interrupting`, - ); - } - yield* Effect.all(forceStops.values(), { concurrency: "unbounded" }); - yield* Fiber.await(stopFiber); + const stopFiber = yield* Effect.forkChild(stopAll); + const stoppedInTime = yield* Effect.race( + Fiber.await(stopFiber).pipe(Effect.as(true)), + Effect.sleep(Duration.seconds(timeoutSecs)).pipe(Effect.as(false)), + ); + + if (!stoppedInTime) { + for (const def of graph.startOrder) { + yield* logBuffer.append( + def.name, + "stderr", + `[shutdown-timeout] Global shutdown timed out after ${timeoutSecs}s, force-interrupting`, + ); } - }).pipe(lifecycleLock.withPermit), + yield* Effect.all(forceStops.values(), { concurrency: "unbounded" }); + yield* Fiber.await(stopFiber); + } + }).pipe(Semaphore.withPermit(lifecycleLock)), stopService: (name: string) => Effect.gen(function* () { if (lookupDef(name) === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } const affected = restartClosure(name); for (const affectedDef of [...affected].reverse()) { @@ -886,13 +897,13 @@ export class Orchestrator extends Context.Service< yield* FiberMap.remove(fibers, affectedDef.name); yield* sendEvent(affectedDef.name, { _tag: "ProcessExited", exitCode: 143 }); } - }).pipe(lifecycleLock.withPermit), + }).pipe(Semaphore.withPermit(lifecycleLock)), restartService: (name: string, options) => Effect.gen(function* () { const def = lookupDef(name); if (def === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } const affected = restartClosure(name); @@ -906,13 +917,13 @@ export class Orchestrator extends Context.Service< for (const affectedDef of affected) { yield* FiberMap.run(fibers, affectedDef.name, runServiceSafe(affectedDef, options)); } - }).pipe(lifecycleLock.withPermit), + }).pipe(Semaphore.withPermit(lifecycleLock)), updateServiceDefinition: (name: string, def: ServiceDef) => Effect.gen(function* () { const existing = lookupDef(name); if (existing === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } const replacement = def.name === name ? def : { ...def, name }; @@ -920,52 +931,51 @@ export class Orchestrator extends Context.Service< graph.startOrder.map((current) => (current.name === name ? replacement : current)), ); graph = nextGraph; - }).pipe(lifecycleLock.withPermit), + }).pipe(Semaphore.withPermit(lifecycleLock)), getState: (name: string) => Effect.gen(function* () { const svc = services.get(name); if (svc === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } return SubscriptionRef.getUnsafe(svc.state); }), - getAllStates: () => - Effect.sync(() => - graph.startOrder.map((def) => { - const svc = services.get(def.name); - return svc ? SubscriptionRef.getUnsafe(svc.state) : initial(def.name); - }), - ), + getAllStates: Effect.sync(() => + graph.startOrder.map((def) => { + const svc = services.get(def.name); + return svc ? SubscriptionRef.getUnsafe(svc.state) : initial(def.name); + }), + ), stateChanges: (name: string) => Effect.gen(function* () { const svc = services.get(name); if (svc === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } return SubscriptionRef.changes(svc.state); }), - allStateChanges: () => { + allStateChanges: (() => { const streams = graph.startOrder.map((def) => { const svc = services.get(def.name); return svc ? SubscriptionRef.changes(svc.state) : Stream.empty; }); return Stream.mergeAll(streams, { concurrency: "unbounded" }); - }, + })(), waitReady: (name: string) => Effect.gen(function* () { const def = lookupDef(name); if (def === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } yield* waitReadySingle(def); }), - waitAllReady: () => + waitAllReady: Effect.suspend(() => Effect.all( graph.startOrder .filter((def) => { @@ -977,6 +987,7 @@ export class Orchestrator extends Context.Service< .map(waitReadySingle), { concurrency: "unbounded" }, ).pipe(Effect.asVoid), + ), }; }), ); diff --git a/packages/process-compose/src/Orchestrator.unit.test.ts b/packages/process-compose/src/Orchestrator.unit.test.ts index 276779ea3b..110ce317d2 100644 --- a/packages/process-compose/src/Orchestrator.unit.test.ts +++ b/packages/process-compose/src/Orchestrator.unit.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; import { + Clock, Deferred, + Data, Duration, Effect, Exit, @@ -12,12 +14,17 @@ import { Stream, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { FetchHttpClient } from "effect/unstable/http"; import { buildGraph } from "./DependencyGraph.ts"; import { LogBuffer } from "./LogBuffer.ts"; import { Orchestrator } from "./Orchestrator.ts"; import type { OrchestratorConfig, ServiceDef } from "./ServiceDef.ts"; import type { ServiceState } from "./ServiceState.ts"; +class TestFailure extends Data.TaggedError("TestFailure")<{ + readonly message: string; +}> {} + // --- Mock factories --- const encoder = new TextEncoder(); @@ -33,27 +40,29 @@ function mockLogBuffer() { entryEvents.notify(); }), subscribe: (_service: string) => Stream.empty, - subscribeAll: () => Stream.empty, + subscribeAll: Stream.empty, history: (service: string, limit = 100) => - Effect.sync(() => { + Effect.gen(function* () { + const timestamp = yield* Clock.currentTimeMillis; const matching = entries.filter((e) => e.service === service); const sliced = matching.slice(-limit); return sliced.map((e) => ({ - timestamp: Date.now(), + timestamp, service: e.service, stream: e.stream as "stdout" | "stderr", line: e.line, })); }), historyAll: (limit = 100, services?: ReadonlyArray<string>) => - Effect.sync(() => { + Effect.gen(function* () { + const timestamp = yield* Clock.currentTimeMillis; const filtered = services === undefined || services.length === 0 ? entries : entries.filter((entry) => services.includes(entry.service)); const sliced = filtered.slice(-limit); return sliced.map((entry) => ({ - timestamp: Date.now(), + timestamp, service: entry.service, stream: entry.stream as "stdout" | "stderr", line: entry.line, @@ -118,7 +127,7 @@ function createWaitList() { Effect.ensuring(Effect.sync(() => waiters.delete(waiter))), ); if (Option.isNone(result)) { - return yield* Effect.fail(new Error(`Timed out waiting for ${description}`)); + return yield* new TestFailure({ message: `Timed out waiting for ${description}` }); } }); @@ -143,7 +152,7 @@ const waitForState = ( ); return Option.getOrThrowWith( result, - () => new Error(`Timed out waiting for ${name} to become ${description}`), + () => new TestFailure({ message: `Timed out waiting for ${name} to become ${description}` }), ); }); @@ -251,7 +260,7 @@ function setupOrchestrator( const proc = mockChildProcessSpawner(runnerOpts); const log = mockLogBuffer(); const layer = Orchestrator.layer(graph, config).pipe( - Layer.provide(Layer.mergeAll(proc.layer, log.layer)), + Layer.provide(Layer.mergeAll(proc.layer, log.layer, FetchHttpClient.layer)), ); return { graph, proc, log, layer }; } @@ -316,7 +325,7 @@ function setupOrchestratorWithStuckKill( const proc = mockStuckChildProcessSpawner(); const log = mockLogBuffer(); const layer = Orchestrator.layer(graph, config).pipe( - Layer.provide(Layer.mergeAll(proc.layer, log.layer)), + Layer.provide(Layer.mergeAll(proc.layer, log.layer, FetchHttpClient.layer)), ); return { graph, proc, log, layer }; } @@ -420,7 +429,7 @@ describe("Orchestrator", () => { const { layer } = setupOrchestrator([svc("a"), svc("b")]); return Effect.gen(function* () { const orc = yield* Orchestrator; - const states = yield* orc.getAllStates(); + const states = yield* orc.getAllStates; expect(states.length).toBe(2); const names = states.map((s) => s.name).sort(); expect(names).toEqual(["a", "b"]); @@ -450,7 +459,7 @@ describe("Orchestrator", () => { yield* orc.start(); yield* proc.waitForSpawnCount(2); expect(proc.spawned.length).toBe(2); - yield* orc.stop(); + yield* orc.stop; yield* proc.waitForKillCount(2); // Kill should have been called for each service (via finalizer) expect(proc.killed.length).toBeGreaterThanOrEqual(2); @@ -550,7 +559,7 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* proc.waitForSpawnCount(1); - yield* orc.stop(); + yield* orc.stop; expect(cleanedUp).toBe(true); }).pipe(Effect.provide(layer), Effect.scoped); }); @@ -740,7 +749,7 @@ describe("Orchestrator", () => { Effect.suspend(() => { attempts++; return attempts === 1 - ? Effect.fail(new Error("first attempt failed")) + ? Effect.fail(new TestFailure({ message: "first attempt failed" })) : Effect.void; }), }, @@ -775,7 +784,7 @@ describe("Orchestrator", () => { Effect.suspend(() => { attempts++; return attempts === 1 - ? Effect.fail(new Error("first attempt failed")) + ? Effect.fail(new TestFailure({ message: "first attempt failed" })) : Effect.void; }), }, @@ -813,7 +822,7 @@ describe("Orchestrator", () => { Effect.suspend(() => { attempts++; return attempts === 1 - ? Effect.fail(new Error("first attempt failed")) + ? Effect.fail(new TestFailure({ message: "first attempt failed" })) : Effect.void; }), }, @@ -857,7 +866,7 @@ describe("Orchestrator", () => { Effect.suspend(() => { attempts++; return attempts === 1 - ? Effect.fail(new Error("first attempt failed")) + ? Effect.fail(new TestFailure({ message: "first attempt failed" })) : Effect.void; }), }, @@ -897,7 +906,7 @@ describe("Orchestrator", () => { Effect.suspend(() => { attempts++; return attempts === 1 - ? Effect.fail(new Error("first attempt failed")) + ? Effect.fail(new TestFailure({ message: "first attempt failed" })) : Effect.void; }), }, @@ -1596,7 +1605,12 @@ describe("Orchestrator", () => { successThreshold: 1, failureThreshold: 1, }, - hooks: [{ on: "healthy", run: () => Effect.fail(new Error("recovery failed")) }], + hooks: [ + { + on: "healthy", + run: () => Effect.fail(new TestFailure({ message: "recovery failed" })), + }, + ], }), ], { @@ -1699,7 +1713,7 @@ describe("Orchestrator", () => { hooks: [ { on: "started", - run: (_log) => Effect.fail(new Error("migration failed")), + run: (_log) => Effect.fail(new TestFailure({ message: "migration failed" })), }, ], }), @@ -1723,7 +1737,7 @@ describe("Orchestrator", () => { hooks: [ { on: "started", - run: (_log) => Effect.fail(new Error("optional hook failed")), + run: (_log) => Effect.fail(new TestFailure({ message: "optional hook failed" })), failurePolicy: "ignore", }, ], @@ -1886,7 +1900,7 @@ describe("Orchestrator", () => { run: (log) => Effect.gen(function* () { yield* log("stderr", "attempting migration..."); - yield* Effect.fail(new Error("migration failed")); + return yield* new TestFailure({ message: "migration failed" }); }), failurePolicy: "ignore", }, @@ -1918,8 +1932,8 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* proc.waitForSpawnCount(3); - yield* orc.stop(); - const states = yield* orc.getAllStates(); + yield* orc.stop; + const states = yield* orc.getAllStates; for (const s of states) { expect(s.status).toBe("Stopped"); } @@ -1940,7 +1954,7 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* proc.waitForSpawn("api"); - yield* orc.stop(); + yield* orc.stop; // api must stop before db (dependent before dependency) const killOrder = proc.killed.map((record) => record.command); @@ -1967,8 +1981,8 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* proc.waitForSpawnCount(4); - yield* orc.stop(); - const states = yield* orc.getAllStates(); + yield* orc.stop; + const states = yield* orc.getAllStates; for (const s of states) { expect(s.status).toBe("Stopped"); } @@ -1987,8 +2001,8 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* waitForHealthy(orc, "a"); - yield* orc.stop(); - const states = yield* orc.getAllStates(); + yield* orc.stop; + const states = yield* orc.getAllStates; for (const s of states) { expect(s.status).toBe("Stopped"); } @@ -2004,9 +2018,9 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* waitForState(orc, "stuck", (state) => state.status === "Healthy", "Healthy"); - const before = Date.now(); - yield* orc.stop(); - const elapsed = Date.now() - before; + const before = yield* Clock.currentTimeMillis; + yield* orc.stop; + const elapsed = (yield* Clock.currentTimeMillis) - before; expect(elapsed).toBeLessThan(3000); const state = yield* orc.getState("stuck"); expect(state.status).toBe("Stopped"); @@ -2025,7 +2039,7 @@ describe("Orchestrator", () => { const orc = yield* Orchestrator; yield* orc.start(); yield* waitForState(orc, "stuck", (state) => state.status === "Healthy", "Healthy"); - yield* orc.stop(); + yield* orc.stop; const timeoutEntries = log.entries.filter((e) => e.line.includes("[shutdown-timeout]")); expect(timeoutEntries.length).toBeGreaterThanOrEqual(1); }).pipe(Effect.provide(layer), Effect.scoped); @@ -2038,7 +2052,7 @@ describe("Orchestrator", () => { return Effect.gen(function* () { const orc = yield* Orchestrator; yield* orc.startService("api", { - beforeStart: () => Effect.fail(new Error("port reservation failed")), + beforeStart: () => Effect.fail(new TestFailure({ message: "port reservation failed" })), }); const error = yield* orc.waitReady("api").pipe(Effect.flip); @@ -2132,7 +2146,7 @@ describe("Orchestrator", () => { prepareCalls++; return prepareCalls === 1 ? Effect.void - : Effect.fail(new Error("port reservation failed")); + : Effect.fail(new TestFailure({ message: "port reservation failed" })); }), }); @@ -2302,7 +2316,12 @@ describe("Orchestrator", () => { const { layer, proc } = setupOrchestrator( [ svc("a", { - hooks: [{ on: "healthy", run: () => Effect.fail(new Error("warmup failed")) }], + hooks: [ + { + on: "healthy", + run: () => Effect.fail(new TestFailure({ message: "warmup failed" })), + }, + ], }), ], { exitDelay: "5 seconds" }, @@ -2434,7 +2453,7 @@ describe("Orchestrator", () => { hooks: [ { on: "started", - run: (_log) => Effect.fail(new Error("startup failed")), + run: (_log) => Effect.fail(new TestFailure({ message: "startup failed" })), }, ], }), @@ -2487,8 +2506,8 @@ describe("Orchestrator", () => { return Effect.gen(function* () { const orc = yield* Orchestrator; yield* orc.start(); - yield* orc.waitAllReady(); - const states = yield* orc.getAllStates(); + yield* orc.waitAllReady; + const states = yield* orc.getAllStates; for (const s of states) { expect(s.status).toBe("Healthy"); } @@ -2504,7 +2523,7 @@ describe("Orchestrator", () => { hooks: [ { on: "started", - run: (_log) => Effect.fail(new Error("crash")), + run: (_log) => Effect.fail(new TestFailure({ message: "crash" })), }, ], }), @@ -2514,7 +2533,7 @@ describe("Orchestrator", () => { return Effect.gen(function* () { const orc = yield* Orchestrator; yield* orc.start(); - const exit = yield* orc.waitAllReady().pipe(Effect.exit); + const exit = yield* orc.waitAllReady.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); }).pipe(Effect.provide(layer), Effect.scoped); }); diff --git a/packages/process-compose/src/ServiceDef.ts b/packages/process-compose/src/ServiceDef.ts index 6cdc03150b..6078bfadbb 100644 --- a/packages/process-compose/src/ServiceDef.ts +++ b/packages/process-compose/src/ServiceDef.ts @@ -46,9 +46,12 @@ export type HookTrigger = "started" | "healthy"; export type HookLog = (stream: "stdout" | "stderr", line: string) => Effect.Effect<void>; +/** Tagged failures preserve the error identity across lifecycle boundaries. */ +export type ServiceEffectError = { readonly _tag: string }; + export interface LifecycleHook { readonly on: HookTrigger; - readonly run: (log: HookLog) => Effect.Effect<void, unknown>; + readonly run: (log: HookLog) => Effect.Effect<void, ServiceEffectError>; readonly timeoutSeconds?: number; readonly failurePolicy?: "fail" | "ignore"; } @@ -83,7 +86,7 @@ export interface ServiceDef { readonly shutdown?: ShutdownConfig; readonly restart?: RestartPolicy; readonly maxRestarts?: number; - readonly cleanup?: Effect.Effect<void, unknown>; + readonly cleanup?: Effect.Effect<void, ServiceEffectError>; readonly supervision?: SupervisionConfig; readonly hooks?: ReadonlyArray<LifecycleHook>; readonly enabled?: boolean; @@ -95,7 +98,7 @@ export interface OrchestratorConfig { export interface ServiceStartOptions { /** Runs when a service lifecycle starts and again after each process exit before backoff. */ - readonly beforeStart?: (name: string) => Effect.Effect<void, unknown>; + readonly beforeStart?: (name: string) => Effect.Effect<void, ServiceEffectError>; /** Runs after dependencies are satisfied and immediately before each spawn. */ readonly beforeSpawn?: (name: string) => Effect.Effect<void>; } diff --git a/packages/process-compose/src/ServiceState.ts b/packages/process-compose/src/ServiceState.ts index fbd19f777c..75b0d20894 100644 --- a/packages/process-compose/src/ServiceState.ts +++ b/packages/process-compose/src/ServiceState.ts @@ -25,6 +25,17 @@ export class ServiceState extends Data.Class<{ readonly desired: ServiceDesiredState; }> {} +export const fields = (state: ServiceState) => ({ + name: state.name, + status: state.status, + pid: state.pid, + exitCode: state.exitCode, + restartCount: state.restartCount, + startedAt: state.startedAt, + error: state.error, + desired: state.desired, +}); + export const initial = (name: string, desired: ServiceDesiredState = "inactive"): ServiceState => new ServiceState({ name, diff --git a/packages/process-compose/src/ServiceState.unit.test.ts b/packages/process-compose/src/ServiceState.unit.test.ts index e47de705f5..50ed1640d3 100644 --- a/packages/process-compose/src/ServiceState.unit.test.ts +++ b/packages/process-compose/src/ServiceState.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ServiceState, initial } from "./ServiceState.ts"; +import { fields as serviceStateFields, ServiceState, initial } from "./ServiceState.ts"; describe("ServiceState", () => { it("creates initial state with Pending status", () => { @@ -23,10 +23,10 @@ describe("ServiceState", () => { it("can transition via Data.Class copy", () => { const state = initial("postgres"); const running = new ServiceState({ - ...state, + ...serviceStateFields(state), status: "Running", pid: 1234, - startedAt: Date.now(), + startedAt: 1_700_000_000_000, }); expect(running.status).toBe("Running"); expect(running.pid).toBe(1234); diff --git a/packages/process-compose/src/ServiceTransition.ts b/packages/process-compose/src/ServiceTransition.ts index 4cb61da328..77833999a8 100644 --- a/packages/process-compose/src/ServiceTransition.ts +++ b/packages/process-compose/src/ServiceTransition.ts @@ -1,5 +1,5 @@ import { Effect, Match, SubscriptionRef } from "effect"; -import { ServiceState, type ServiceStatus } from "./ServiceState.ts"; +import { fields as serviceStateFields, ServiceState, type ServiceStatus } from "./ServiceState.ts"; // --------------------------------------------------------------------------- // Events @@ -75,14 +75,15 @@ const transitionStatuses = Match.type<ServiceEvent>().pipe( const applyTransition = Match.type<ServiceEvent>().pipe( Match.tag( "DependenciesSatisfied", - () => (state: ServiceState) => new ServiceState({ ...state, status: "Starting" }), + () => (state: ServiceState) => + new ServiceState({ ...serviceStateFields(state), status: "Starting" }), ), Match.tag( "DependencyFailed", "SpawnFailed", (event) => (state: ServiceState) => new ServiceState({ - ...state, + ...serviceStateFields(state), status: "Failed", pid: null, exitCode: null, @@ -93,7 +94,7 @@ const applyTransition = Match.type<ServiceEvent>().pipe( "ProcessSpawned", (event) => (state: ServiceState) => new ServiceState({ - ...state, + ...serviceStateFields(state), status: "Running", pid: event.pid, startedAt: event.startedAt, @@ -101,21 +102,23 @@ const applyTransition = Match.type<ServiceEvent>().pipe( ), Match.tag( "HealthCheckPassed", - () => (state: ServiceState) => new ServiceState({ ...state, status: "Healthy" }), + () => (state: ServiceState) => + new ServiceState({ ...serviceStateFields(state), status: "Healthy" }), ), Match.tag( "HealthCheckFailed", - () => (state: ServiceState) => new ServiceState({ ...state, status: "Unhealthy" }), + () => (state: ServiceState) => + new ServiceState({ ...serviceStateFields(state), status: "Unhealthy" }), ), Match.tag( "ProcessTerminated", - () => (state: ServiceState) => new ServiceState({ ...state, pid: null }), + () => (state: ServiceState) => new ServiceState({ ...serviceStateFields(state), pid: null }), ), Match.tag( "UnhealthyRestartExhausted", (event) => (state: ServiceState) => new ServiceState({ - ...state, + ...serviceStateFields(state), status: "Failed", pid: null, exitCode: null, @@ -125,18 +128,23 @@ const applyTransition = Match.type<ServiceEvent>().pipe( Match.tag("ProcessExited", (event) => (state: ServiceState) => { const status: ServiceStatus = state.status === "Stopping" ? "Stopped" : event.exitCode === 0 ? "Stopped" : "Failed"; - return new ServiceState({ ...state, status, pid: null, exitCode: event.exitCode }); + return new ServiceState({ + ...serviceStateFields(state), + status, + pid: null, + exitCode: event.exitCode, + }); }), Match.tag("StopRequested", () => (state: ServiceState) => { const stopStatus = state.status === "Pending" || state.status === "Restarting" ? "Stopped" : "Stopping"; - return new ServiceState({ ...state, status: stopStatus }); + return new ServiceState({ ...serviceStateFields(state), status: stopStatus }); }), Match.tag( "RestartTriggered", (event) => (state: ServiceState) => new ServiceState({ - ...state, + ...serviceStateFields(state), status: "Restarting", pid: null, restartCount: event.restartCount, @@ -146,7 +154,7 @@ const applyTransition = Match.type<ServiceEvent>().pipe( "BackoffElapsed", () => (state: ServiceState) => new ServiceState({ - ...state, + ...serviceStateFields(state), status: "Starting", pid: null, exitCode: null, @@ -157,7 +165,12 @@ const applyTransition = Match.type<ServiceEvent>().pipe( Match.tag( "HookFailed", (event) => (state: ServiceState) => - new ServiceState({ ...state, status: "Failed", pid: null, error: event.error }), + new ServiceState({ + ...serviceStateFields(state), + status: "Failed", + pid: null, + error: event.error, + }), ), Match.exhaustive, ); diff --git a/packages/process-compose/src/ServiceTransition.unit.test.ts b/packages/process-compose/src/ServiceTransition.unit.test.ts index 12b175f1a4..29b6e23c63 100644 --- a/packages/process-compose/src/ServiceTransition.unit.test.ts +++ b/packages/process-compose/src/ServiceTransition.unit.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { Match } from "effect"; import { applyEvent, type ServiceEvent } from "./ServiceTransition.ts"; -import { ServiceState, initial, type ServiceStatus } from "./ServiceState.ts"; +import { + fields as serviceStateFields, + ServiceState, + initial, + type ServiceStatus, +} from "./ServiceState.ts"; const make = ( name: string, @@ -15,7 +20,7 @@ const make = ( }> = {}, ): ServiceState => new ServiceState({ - ...initial(name), + ...serviceStateFields(initial(name)), ...overrides, }); diff --git a/packages/process-compose/src/SupervisorRuntime.unit.test.ts b/packages/process-compose/src/SupervisorRuntime.unit.test.ts index 12a6151f7b..f843322050 100644 --- a/packages/process-compose/src/SupervisorRuntime.unit.test.ts +++ b/packages/process-compose/src/SupervisorRuntime.unit.test.ts @@ -1,253 +1,348 @@ -import { spawn } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { describe, expect, test } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; +import { layer as BunChildProcessSpawnerLayer } from "@effect/platform-bun/BunChildProcessSpawner"; +import { layer as BunFileSystemLayer } from "@effect/platform-bun/BunFileSystem"; +import { layer as BunPathLayer } from "@effect/platform-bun/BunPath"; +import { + Data, + Duration, + Effect, + Fiber, + FileSystem, + Layer, + Path, + Schedule, + Schema, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type { PlatformError } from "effect/PlatformError"; import { makeSupervisorRuntimeEnv, withoutSupervisorRuntimeEnv } from "./supervisor-protocol.ts"; +const platformLayer = Layer.mergeAll( + BunChildProcessSpawnerLayer.pipe(Layer.provide(Layer.mergeAll(BunFileSystemLayer, BunPathLayer))), + BunFileSystemLayer, + BunPathLayer, +); const supervisorRuntimePath = fileURLToPath(new URL("./supervisor-runtime.ts", import.meta.url)); const supervisorProtocolPath = fileURLToPath(new URL("./supervisor-protocol.ts", import.meta.url)); - type SupervisorEntry = "source path" | "compiled self-dispatch"; -const spawnSupervisor = (entry: SupervisorEntry, encodedConfig: string) => { - if (entry === "source path") { - return spawn(process.execPath, [supervisorRuntimePath, encodedConfig], { - stdio: ["pipe", "ignore", "ignore"], - }); - } - - const runtimeUrl = pathToFileURL(supervisorRuntimePath).href; - const protocolUrl = pathToFileURL(supervisorProtocolPath).href; - const dispatch = [ - `import { runSupervisorRuntimeFromEnv } from ${JSON.stringify(runtimeUrl)};`, - `import { isSupervisorRuntimeRequested } from ${JSON.stringify(protocolUrl)};`, - `if (!isSupervisorRuntimeRequested()) throw new Error("supervisor dispatch not requested");`, - `runSupervisorRuntimeFromEnv();`, - ].join("\n"); - return spawn(process.execPath, ["--eval", dispatch], { - env: makeSupervisorRuntimeEnv(encodedConfig, { - ...process.env, - PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", - }), - stdio: ["pipe", "ignore", "ignore"], +class TestFailure extends Data.TaggedError("TestFailure")<{ + readonly message: string; +}> {} + +const spawnSupervisor = (entry: SupervisorEntry, encodedConfig: string) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + if (entry === "source path") { + return yield* spawner.spawn( + ChildProcess.make(process.execPath, [supervisorRuntimePath, encodedConfig], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }), + ); + } + const runtimeUrl = pathToFileURL(supervisorRuntimePath).href; + const protocolUrl = pathToFileURL(supervisorProtocolPath).href; + const dispatch = [ + `import { runSupervisorRuntimeFromEnv } from ${encodeJsonString(runtimeUrl)};`, + `import { isSupervisorRuntimeRequested } from ${encodeJsonString(protocolUrl)};`, + `if (!isSupervisorRuntimeRequested()) throw new Error("supervisor dispatch not requested");`, + `runSupervisorRuntimeFromEnv();`, + ].join("\n"); + return yield* spawner.spawn( + ChildProcess.make(process.execPath, ["--eval", dispatch], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + env: makeSupervisorRuntimeEnv(encodedConfig, { + ...process.env, + PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", + }), + }), + ); }); -}; -const waitFor = async ( - predicate: () => boolean, - opts: { - readonly timeoutMs?: number; - readonly intervalMs?: number; - } = {}, -): Promise<void> => { - const timeoutMs = opts.timeoutMs ?? 5_000; - const intervalMs = opts.intervalMs ?? 50; - const deadline = Date.now() + timeoutMs; +const encodeJsonString = (value: string) => + Schema.encodeUnknownSync(Schema.fromJsonString(Schema.String))(value); +const jsonSchema = Schema.fromJsonString(Schema.Unknown); +const encodeJson = (value: unknown) => Schema.encodeUnknownSync(jsonSchema)(value); +const decodeJson = (value: string) => Schema.decodeSync(jsonSchema)(value); +const encodeConfig = (config: object) => Buffer.from(encodeJson(config)).toString("base64url"); +const waitFor = <R>( + condition: Effect.Effect<boolean, PlatformError, R>, + description: string, +): Effect.Effect<void, PlatformError | TestFailure, R> => + condition.pipe( + Effect.filterOrFail( + (ready) => ready, + () => new TestFailure({ message: `Timed out waiting for ${description}` }), + ), + Effect.retry(Schedule.spaced(Duration.millis(50))), + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => + Effect.fail(new TestFailure({ message: `Timed out waiting for ${description}` })), + }), + Effect.asVoid, + ); - while (Date.now() < deadline) { - if (predicate()) { - return; +const waitForPath = ( + fs: FileSystem.FileSystem, + directory: string, + path: string, + expected: boolean, + description: string, +): Effect.Effect<void, PlatformError | TestFailure, FileSystem.FileSystem> => + Effect.scoped( + Effect.gen(function* () { + const watcher = yield* fs.watch(directory).pipe( + Stream.filterEffect(() => + fs.exists(path).pipe(Effect.map((exists) => exists === expected)), + ), + Stream.runHead, + Effect.asVoid, + Effect.forkChild({ startImmediately: true }), + ); + if ((yield* fs.exists(path)) === expected) { + yield* Fiber.interrupt(watcher).pipe(Effect.ignore); + return; + } + yield* Fiber.join(watcher).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => + Effect.fail(new TestFailure({ message: `Timed out waiting for ${description}` })), + }), + ); + }), + ); +const waitForExit = (supervisor: ChildProcessSpawner.ChildProcessHandle) => + supervisor.exitCode.pipe(Effect.exit, Effect.asVoid); +const closeStdin = (supervisor: ChildProcessSpawner.ChildProcessHandle) => + Stream.run(Stream.make(new Uint8Array()), supervisor.stdin).pipe( + Effect.timeout(Duration.millis(100)), + Effect.ignore, + Effect.andThen( + Effect.sync(() => { + try { + process.kill(supervisor.pid, "SIGTERM"); + } catch {} + }), + ), + ); +const isPidAlive = (pid: number) => + Effect.sync(() => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; } - - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - - throw new Error("Timed out waiting for condition"); -}; - -const isPidAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -}; + }); describe("supervisor-runtime", () => { - test.each<SupervisorEntry>(["source path", "compiled self-dispatch"])( + it.live.each<SupervisorEntry>(["source path", "compiled self-dispatch"])( "%s kills the child tree and runs validated orphan cleanup when parent stdin closes", - { timeout: 15_000 }, - async (entry) => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupMarker = path.join(tempDir, "cleanup-command-ran"); - const cleanupEnvironmentMarker = path.join(tempDir, "cleanup-environment.json"); - const childPidFile = path.join(tempDir, "child.pid"); - const grandchildPidFile = path.join(tempDir, "grandchild.pid"); - const readyFile = path.join(tempDir, "ready"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - mkdirSync(cleanupDir); - writeFileSync( - childScriptPath, - [ - `import { spawn } from "node:child_process";`, - `import { writeFileSync } from "node:fs";`, - `writeFileSync(${JSON.stringify(childPidFile)}, String(process.pid));`, - `const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, - `if (grandchild.pid != null) writeFileSync(${JSON.stringify(grandchildPidFile)}, String(grandchild.pid));`, - `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, - `process.on("SIGTERM", () => {});`, - `process.on("SIGINT", () => {});`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ); - - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 100, - cleanup: [ - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { writeFileSync } = require("node:fs");`, - `writeFileSync(process.argv[1], process.argv[2]);`, - `writeFileSync(process.argv[3], JSON.stringify({`, - ` run: process.env.PROCESS_COMPOSE_RUN_SUPERVISOR,`, - ` config: process.env.PROCESS_COMPOSE_SUPERVISOR_CONFIG,`, - ` dispatch: process.env.PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH,`, - `}));`, - ].join("\n"), - cleanupMarker, - "literal; $(not-run) & value", - cleanupEnvironmentMarker, - ], - }, - ], + (entry) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-", + }); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupMarker = path.join(tempDir, "cleanup-command-ran"); + const cleanupEnvironmentMarker = path.join(tempDir, "cleanup-environment.json"); + const childPidFile = path.join(tempDir, "child.pid"); + const grandchildPidFile = path.join(tempDir, "grandchild.pid"); + const readyFile = path.join(tempDir, "ready"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.makeDirectory(cleanupDir); + yield* fs.writeFileString( + childScriptPath, + [ + `import { spawn } from "node:child_process";`, + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${encodeJsonString(childPidFile)}, String(process.pid));`, + `const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, + `if (grandchild.pid != null) writeFileSync(${encodeJsonString(grandchildPidFile)}, String(grandchild.pid));`, + `writeFileSync(${encodeJsonString(readyFile)}, "ready");`, + `process.on("SIGTERM", () => {});`, + `process.on("SIGINT", () => {});`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ); + const encodedConfig = encodeConfig({ + command: process.execPath, + args: [childScriptPath], + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 100, + cleanup: [ + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + [ + `const { writeFileSync } = require("node:fs");`, + `writeFileSync(process.argv[1], process.argv[2]);`, + `writeFileSync(process.argv[3], JSON.stringify({ run: process.env.PROCESS_COMPOSE_RUN_SUPERVISOR, config: process.env.PROCESS_COMPOSE_SUPERVISOR_CONFIG, dispatch: process.env.PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH }));`, + ].join("\n"), + cleanupMarker, + "literal; $(not-run) & value", + cleanupEnvironmentMarker, + ], + }, + ], + }); + const supervisor = yield* spawnSupervisor(entry, encodedConfig); + yield* waitForPath(fs, tempDir, readyFile, true, "child readiness"); + const childPid = Number.parseInt(yield* fs.readFileString(childPidFile), 10); + const grandchildPid = Number.parseInt(yield* fs.readFileString(grandchildPidFile), 10); + yield* closeStdin(supervisor); + yield* waitForExit(supervisor); + yield* waitForPath(fs, tempDir, cleanupMarker, true, "cleanup command marker"); + expect(yield* fs.readFileString(cleanupMarker)).toBe("literal; $(not-run) & value"); + expect(decodeJson(yield* fs.readFileString(cleanupEnvironmentMarker))).toEqual({}); + yield* waitForPath(fs, tempDir, cleanupDir, false, "cleanup removal"); + yield* waitFor(isPidAlive(childPid).pipe(Effect.map((alive) => !alive)), "child exit"); + yield* waitFor( + isPidAlive(grandchildPid).pipe(Effect.map((alive) => !alive)), + "grandchild exit", + ); }), - ).toString("base64url"); - - const supervisor = spawnSupervisor(entry, encodedConfig); - - try { - await waitFor(() => existsSync(readyFile)); - - const childPid = Number.parseInt(readFileSync(childPidFile, "utf8"), 10); - const grandchildPid = Number.parseInt(readFileSync(grandchildPidFile, "utf8"), 10); - - supervisor.stdin.end(); - - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - await waitFor(() => !existsSync(cleanupDir), { timeoutMs: 10_000 }); - await waitFor(() => existsSync(cleanupMarker), { timeoutMs: 10_000 }); - expect(readFileSync(cleanupMarker, "utf8")).toBe("literal; $(not-run) & value"); - expect(JSON.parse(readFileSync(cleanupEnvironmentMarker, "utf8"))).toEqual({}); - await waitFor(() => !isPidAlive(childPid), { timeoutMs: 10_000 }); - await waitFor(() => !isPidAlive(grandchildPid), { timeoutMs: 10_000 }); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, + ).pipe(Effect.provide(platformLayer)), + { timeout: 15_000 }, ); - test( + it.live( "runs cleanup exactly once when graceful shutdown races with child exit", - { timeout: 15_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-race-")); - const cleanupMarker = path.join(tempDir, "cleanup-runs"); - const readyFile = path.join(tempDir, "ready"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - writeFileSync( - childScriptPath, - [ - `import { writeFileSync } from "node:fs";`, - `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, - `process.on("SIGTERM", () => setTimeout(() => process.exit(0), 25));`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ); - - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 1_000, - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { appendFileSync } = require("node:fs");`, - `appendFileSync(${JSON.stringify(cleanupMarker)}, "cleanup\\n");`, - `setTimeout(() => process.exit(0), 250);`, - ].join("\n"), + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-race-", + }); + const cleanupMarker = path.join(tempDir, "cleanup-runs"); + const readyFile = path.join(tempDir, "ready"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.writeFileString( + childScriptPath, + [ + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${encodeJsonString(readyFile)}, "ready");`, + `process.on("SIGTERM", () => setTimeout(() => process.exit(0), 25));`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, + args: [childScriptPath], + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 1_000, + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + `const { appendFileSync } = require("node:fs"); appendFileSync(${encodeJsonString(cleanupMarker)}, "cleanup\\n"); setTimeout(() => process.exit(0), 250);`, + ], + }, ], - }, - ], + }), + ); + yield* waitForPath(fs, tempDir, readyFile, true, "child readiness"); + yield* closeStdin(supervisor); + yield* waitForExit(supervisor); + expect(yield* fs.readFileString(cleanupMarker)).toBe("cleanup\n"); }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => existsSync(readyFile)); - supervisor.stdin.end(); - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - - expect(readFileSync(cleanupMarker, "utf8")).toBe("cleanup\n"); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, + ).pipe(Effect.provide(platformLayer)), + { timeout: 12_000 }, ); - test( - "exits successfully when graceful shutdown races with cleanup-less child exit", - { timeout: 15_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-exit-race-")); - const readyFile = path.join(tempDir, "ready"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - writeFileSync( - childScriptPath, - [ - `import { writeFileSync } from "node:fs";`, - `writeFileSync(${JSON.stringify(readyFile)}, "ready");`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ); + it.live("exits successfully when graceful shutdown races with cleanup-less child exit", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-exit-race-", + }); + const readyFile = path.join(tempDir, "ready"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.writeFileString( + childScriptPath, + [ + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${encodeJsonString(readyFile)}, "ready");`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, + args: [childScriptPath], + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 1_000, + }), + ); + yield* waitForPath(fs, tempDir, readyFile, true, "child readiness"); + yield* closeStdin(supervisor); + yield* waitForExit(supervisor); + expect(yield* supervisor.exitCode).toBe(0); + }), + ).pipe(Effect.provide(platformLayer)), + ); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 1_000, + it.live( + "exits promptly with failure when the child dies by signal while the owner remains active", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-signal-", + }); + const readyFile = path.join(tempDir, "ready"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.writeFileString( + childScriptPath, + [ + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${encodeJsonString(readyFile)}, "ready");`, + `process.kill(process.pid, "SIGTERM");`, + ].join("\n"), + ); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, + args: [childScriptPath], + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 100, + }), + ); + yield* waitForPath(fs, tempDir, readyFile, true, "child readiness"); + const exitCode = yield* supervisor.exitCode.pipe(Effect.timeout(Duration.seconds(5))); + expect(exitCode).toBe(1); }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => existsSync(readyFile)); - supervisor.stdin.end(); - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - - expect(supervisor.exitCode).toBe(0); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, + ).pipe(Effect.provide(platformLayer)), + { timeout: 10_000 }, ); - test.each([ + it.live.each([ [ "non-string command argument", { _tag: "RunCommand", executable: process.execPath, args: [42] }, @@ -258,173 +353,171 @@ describe("supervisor-runtime", () => { { _tag: "RunCommand", executable: process.execPath, args: [], timeoutMs: 0 }, ], ["invalid path option", { _tag: "RemovePath", path: "/tmp/example", recursive: "yes" }], - ])("rejects a malformed cleanup contract with %s before spawning", async (_name, cleanup) => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-invalid-")); - const childMarker = path.join(tempDir, "child-started"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: ["-e", `require("node:fs").writeFileSync(${JSON.stringify(childMarker)}, "started")`], - cleanup: [cleanup], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => supervisor.exitCode != null); - expect(supervisor.exitCode).not.toBe(0); - expect(existsSync(childMarker)).toBe(false); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - test("removes supervisor protocol variables from the managed child environment", () => { - const childEnv = withoutSupervisorRuntimeEnv({ - KEEP_ME: "value", - PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", - PROCESS_COMPOSE_RUN_SUPERVISOR: "1", - PROCESS_COMPOSE_SUPERVISOR_CONFIG: "encoded", - }); - - expect(childEnv).toEqual({ KEEP_ME: "value" }); - }); - - test( - "bounds a cleanup command tree by its timeout and continues remaining cleanup", - { timeout: 12_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupWorkerPidFile = path.join(tempDir, "cleanup-worker.pid"); - const childScriptPath = path.join(tempDir, "child.mjs"); - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, "process.exit(0);\n"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, - args: [ - "-e", - [ - `const { spawn } = require("node:child_process");`, - `const { writeFileSync } = require("node:fs");`, - `const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, - `writeFileSync(${JSON.stringify(cleanupWorkerPidFile)}, String(worker.pid));`, - `setInterval(() => {}, 1000);`, - ].join("\n"), - ], - // Budget starts at spawn, so it must outlast node booting, spawning - // the worker, and writing the pid file. 100ms lost that race on CI. - timeoutMs: 2_000, - }, - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - ], - }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); - - try { - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - expect(supervisor.exitCode).toBe(0); - expect(existsSync(cleanupDir)).toBe(false); - const cleanupWorkerPid = Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10); - expect(Number.isSafeInteger(cleanupWorkerPid)).toBe(true); - await waitFor(() => !isPidAlive(cleanupWorkerPid), { timeoutMs: 10_000 }); - } finally { - supervisor.kill("SIGKILL"); - if (existsSync(cleanupWorkerPidFile)) { - try { - process.kill( - Number.parseInt(readFileSync(cleanupWorkerPidFile, "utf8"), 10), - "SIGKILL", - ); - } catch {} - } - rmSync(tempDir, { recursive: true, force: true }); - } - }, - ); - - test("bounds a cleanup command when no timeout is configured", { timeout: 12_000 }, async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-timeout-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const cleanupPidFile = path.join(tempDir, "cleanup.pid"); - const childScriptPath = path.join(tempDir, "child.mjs"); - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, "process.exit(0);\n"); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - cleanup: [ - { - _tag: "RunCommand", - executable: process.execPath, + ] as const)("rejects a malformed cleanup contract with %s before spawning", ([_name, cleanup]) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-invalid-", + }); + const childMarker = path.join(tempDir, "child-started"); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, args: [ "-e", - `require("node:fs").writeFileSync(${JSON.stringify(cleanupPidFile)}, String(process.pid)); setInterval(() => {}, 1000)`, + `require("node:fs").writeFileSync(${encodeJsonString(childMarker)}, "started")`, ], - }, - { _tag: "RemovePath", path: cleanupDir, recursive: true }, - ], + cleanup: [cleanup], + }), + ); + yield* waitForExit(supervisor); + expect(yield* supervisor.exitCode).not.toBe(0); + expect(yield* fs.exists(childMarker)).toBe(false); }), - ).toString("base64url"); - const supervisor = spawnSupervisor("source path", encodedConfig); + ).pipe(Effect.provide(platformLayer)), + ); - try { - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 8_000 }); - expect(supervisor.exitCode).toBe(0); - expect(existsSync(cleanupDir)).toBe(false); - } finally { - supervisor.kill("SIGKILL"); - if (existsSync(cleanupPidFile)) { - try { - process.kill(Number.parseInt(readFileSync(cleanupPidFile, "utf8"), 10), "SIGKILL"); - } catch {} - } - rmSync(tempDir, { recursive: true, force: true }); - } + it("removes supervisor protocol variables from the managed child environment", () => { + expect( + withoutSupervisorRuntimeEnv({ + KEEP_ME: "value", + PROCESS_COMPOSE_SUPERVISOR_SELF_DISPATCH: "1", + PROCESS_COMPOSE_RUN_SUPERVISOR: "1", + PROCESS_COMPOSE_SUPERVISOR_CONFIG: "encoded", + }), + ).toEqual({ KEEP_ME: "value" }); }); - test( - "runs orphan cleanup when the configured owner pid is already gone", - { timeout: 15_000 }, - async () => { - const tempDir = mkdtempSync(path.join(tmpdir(), "process-compose-supervisor-")); - const cleanupDir = path.join(tempDir, "cleanup-dir"); - const childScriptPath = path.join(tempDir, "child.mjs"); - - mkdirSync(cleanupDir); - writeFileSync(childScriptPath, `setInterval(() => {}, 1000);\n`); + it.live("bounds a cleanup command tree by its timeout and continues remaining cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-timeout-", + }); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupWorkerPidFile = path.join(tempDir, "cleanup-worker.pid"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.makeDirectory(cleanupDir); + yield* fs.writeFileString(childScriptPath, "process.exit(0);\n"); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, + args: [childScriptPath], + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + [ + `const { spawn } = require("node:child_process");`, + `const { writeFileSync } = require("node:fs");`, + `const worker = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });`, + `writeFileSync(${encodeJsonString(cleanupWorkerPidFile)}, String(worker.pid));`, + `setInterval(() => {}, 1000);`, + ].join("\n"), + ], + timeoutMs: 2_000, + }, + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + ], + }), + ); + yield* waitForExit(supervisor); + expect(yield* supervisor.exitCode).toBe(0); + expect(yield* fs.exists(cleanupDir)).toBe(false); + const cleanupWorkerPid = Number.parseInt( + yield* fs.readFileString(cleanupWorkerPidFile), + 10, + ); + expect(Number.isSafeInteger(cleanupWorkerPid)).toBe(true); + yield* waitFor( + isPidAlive(cleanupWorkerPid).pipe(Effect.map((alive) => !alive)), + "cleanup worker exit", + ); + }), + ).pipe(Effect.provide(platformLayer)), + ); - const encodedConfig = Buffer.from( - JSON.stringify({ - command: process.execPath, - args: [childScriptPath], - ownerPid: 999_999_999, - shutdownSignal: "SIGTERM", - shutdownTimeoutMs: 100, - cleanup: [{ _tag: "RemovePath", path: cleanupDir, recursive: true }], + it.live( + "bounds a cleanup command when no timeout is configured", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-timeout-", + }); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const cleanupPidFile = path.join(tempDir, "cleanup.pid"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.makeDirectory(cleanupDir); + yield* fs.writeFileString(childScriptPath, "process.exit(0);\n"); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, + args: [childScriptPath], + cleanup: [ + { + _tag: "RunCommand", + executable: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${encodeJsonString(cleanupPidFile)}, String(process.pid)); setInterval(() => {}, 1000)`, + ], + }, + { _tag: "RemovePath", path: cleanupDir, recursive: true }, + ], + }), + ); + yield* waitForExit(supervisor); + expect(yield* supervisor.exitCode).toBe(0); + expect(yield* fs.exists(cleanupDir)).toBe(false); + const cleanupPid = Number.parseInt(yield* fs.readFileString(cleanupPidFile), 10); + yield* Effect.sync(() => { + try { + process.kill(cleanupPid, "SIGKILL"); + } catch {} + }); }), - ).toString("base64url"); - - const supervisor = spawn(process.execPath, [supervisorRuntimePath, encodedConfig], { - stdio: ["pipe", "ignore", "ignore"], - }); + ).pipe(Effect.provide(platformLayer)), + { timeout: 12_000 }, + ); - try { - await waitFor(() => supervisor.exitCode != null, { timeoutMs: 10_000 }); - await waitFor(() => !existsSync(cleanupDir), { timeoutMs: 10_000 }); - } finally { - supervisor.kill("SIGKILL"); - rmSync(tempDir, { recursive: true, force: true }); - } - }, + it.live("runs orphan cleanup when the configured owner pid is already gone", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ + prefix: "process-compose-supervisor-", + }); + const cleanupDir = path.join(tempDir, "cleanup-dir"); + const childScriptPath = path.join(tempDir, "child.mjs"); + yield* fs.makeDirectory(cleanupDir); + yield* fs.writeFileString(childScriptPath, "setInterval(() => {}, 1000);\n"); + const supervisor = yield* spawnSupervisor( + "source path", + encodeConfig({ + command: process.execPath, + args: [childScriptPath], + ownerPid: 999_999_999, + shutdownSignal: "SIGTERM", + shutdownTimeoutMs: 100, + cleanup: [{ _tag: "RemovePath", path: cleanupDir, recursive: true }], + }), + ); + yield* waitForExit(supervisor); + yield* waitForPath(fs, tempDir, cleanupDir, false, "orphan cleanup"); + }), + ).pipe(Effect.provide(platformLayer)), ); }); diff --git a/packages/process-compose/src/Writable.ts b/packages/process-compose/src/Writable.ts new file mode 100644 index 0000000000..9c3eae03fa --- /dev/null +++ b/packages/process-compose/src/Writable.ts @@ -0,0 +1,57 @@ +import { Effect } from "effect"; +import * as PlatformError from "effect/PlatformError"; + +const writableFailure = (description: string, cause?: unknown) => + PlatformError.systemError({ + _tag: "Unknown", + module: "node:stream", + method: "write", + description, + cause, + }); + +/** + * Writes one chunk to a Node writable, completing only after backpressure is + * released. The callback owns every listener it registers for the write. + */ +export const writeChunk = ( + writable: NodeJS.WritableStream, + chunk: Uint8Array, +): Effect.Effect<void, PlatformError.PlatformError> => + Effect.callback<void, PlatformError.PlatformError>((resume, signal) => { + let settled = false; + + const onDrain = () => finish(Effect.void); + const onError = (cause: unknown) => + finish(Effect.fail(writableFailure("Writable stream emitted an error", cause))); + const onClose = () => + finish(Effect.fail(writableFailure("Writable stream closed before the write completed"))); + const onAbort = () => cleanup(); + + const cleanup = () => { + writable.removeListener("drain", onDrain); + writable.removeListener("error", onError); + writable.removeListener("close", onClose); + signal.removeEventListener("abort", onAbort); + }; + + const finish = (effect: Effect.Effect<void, PlatformError.PlatformError>) => { + if (settled) return; + settled = true; + cleanup(); + resume(effect); + }; + + writable.once("drain", onDrain); + writable.once("error", onError); + writable.once("close", onClose); + signal.addEventListener("abort", onAbort, { once: true }); + + try { + if (writable.write(chunk)) finish(Effect.void); + } catch (cause) { + onError(cause); + } + + return Effect.sync(cleanup); + }); diff --git a/packages/process-compose/src/Writable.unit.test.ts b/packages/process-compose/src/Writable.unit.test.ts new file mode 100644 index 0000000000..87ad56d3aa --- /dev/null +++ b/packages/process-compose/src/Writable.unit.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Fiber } from "effect"; +import { writeChunk } from "./Writable.ts"; + +class ControlledWritable extends EventEmitter { + readonly writable = true; + readonly writes: Array<string> = []; + private blockNext = true; + + write(chunk: string | Uint8Array): boolean { + this.writes.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + if (this.blockNext) { + this.blockNext = false; + return false; + } + return true; + } + + end(): this { + return this; + } +} + +describe("writeChunk", () => { + it.effect("waits for drain before writing the next stream chunk", () => + Effect.gen(function* () { + const writable = new ControlledWritable(); + const fiber = yield* Effect.forkChild( + Effect.forEach( + [new TextEncoder().encode("first"), new TextEncoder().encode("second")], + (chunk) => writeChunk(writable, chunk), + { discard: true }, + ), + { startImmediately: true }, + ); + + yield* Effect.yieldNow; + expect(writable.writes).toEqual(["first"]); + writable.emit("drain"); + yield* Fiber.join(fiber); + expect(writable.writes).toEqual(["first", "second"]); + }), + ); + + it.effect("removes drain, error, and close listeners when cancelled", () => + Effect.gen(function* () { + const writable = new ControlledWritable(); + const fiber = yield* Effect.forkChild(writeChunk(writable, new Uint8Array([1])), { + startImmediately: true, + }); + + yield* Effect.yieldNow; + expect(writable.listenerCount("drain")).toBe(1); + expect(writable.listenerCount("error")).toBe(1); + expect(writable.listenerCount("close")).toBe(1); + + yield* Fiber.interrupt(fiber); + expect(writable.listenerCount("drain")).toBe(0); + expect(writable.listenerCount("error")).toBe(0); + expect(writable.listenerCount("close")).toBe(0); + }), + ); +}); diff --git a/packages/process-compose/src/errors.ts b/packages/process-compose/src/errors.ts index 65dc4ea66f..20639042b7 100644 --- a/packages/process-compose/src/errors.ts +++ b/packages/process-compose/src/errors.ts @@ -18,6 +18,14 @@ export class SpawnError extends Data.TaggedError("SpawnError")<{ readonly cause: unknown; }> {} +export class HookExecutionError extends Data.TaggedError("HookExecutionError")<{ + readonly cause: unknown; +}> {} + +export class CleanupExecutionError extends Data.TaggedError("CleanupExecutionError")<{ + readonly cause: unknown; +}> {} + export class ServiceReadyError extends Data.TaggedError("ServiceReadyError")<{ readonly name: string; readonly reason: string; diff --git a/packages/process-compose/src/index.ts b/packages/process-compose/src/index.ts index 421763613f..8d9b53ede6 100644 --- a/packages/process-compose/src/index.ts +++ b/packages/process-compose/src/index.ts @@ -12,6 +12,7 @@ export type { LifecycleHook, OrchestratorConfig, ServiceStartOptions, + ServiceEffectError, ServiceDef, } from "./ServiceDef.ts"; export { defaults } from "./ServiceDef.ts"; @@ -40,6 +41,7 @@ export { isSupervisorRuntimeRequested, } from "./supervisor-protocol.ts"; export { runSupervisorRuntime, runSupervisorRuntimeFromEnv } from "./supervisor-runtime.ts"; +export { childSignalFromCause } from "./ChildSignal.ts"; export type { ServiceEvent } from "./ServiceTransition.ts"; export { applyEvent, transition } from "./ServiceTransition.ts"; diff --git a/packages/process-compose/src/supervisor-runtime.ts b/packages/process-compose/src/supervisor-runtime.ts index 302278450b..f50163f80d 100644 --- a/packages/process-compose/src/supervisor-runtime.ts +++ b/packages/process-compose/src/supervisor-runtime.ts @@ -1,9 +1,23 @@ -import { execFileSync, spawn } from "node:child_process"; -import { realpathSync, rmSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { Deferred, Duration, Effect, Fiber, Match, Option, Predicate, Schedule } from "effect"; -import type { ChildProcess } from "effect/unstable/process"; +import { BunServices } from "@effect/platform-bun"; +import { + Deferred, + Duration, + Effect, + Exit, + Fiber, + FileSystem, + Match, + Option, + Predicate, + Schedule, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import * as PlatformError from "effect/PlatformError"; import type { ExternalCleanupAction } from "./ServiceDef.ts"; +import { childSignalFromCause } from "./ChildSignal.ts"; +import { writeChunk } from "./Writable.ts"; import { supervisorRuntimeConfigFromEnv, withoutSupervisorRuntimeEnv, @@ -23,7 +37,7 @@ interface SupervisorRuntimeConfig { interface ChildExit { readonly code: number | null; - readonly signal: NodeJS.Signals | null; + readonly signal: ChildProcess.Signal | null; } type SupervisorOutcome = @@ -42,11 +56,7 @@ const isMain = (() => { return false; } - try { - return realpathSync(process.argv[1]) === realpathSync(runtimePath); - } catch { - return process.argv[1] === runtimePath; - } + return process.argv[1] === runtimePath; })(); const getField = (value: object, key: string): unknown => Reflect.get(value, key); @@ -190,60 +200,124 @@ const parseSupervisorRuntimeConfig = (encodedConfig: string): SupervisorRuntimeC }; }; -const killProcessTree = (pid: number, signal: ChildProcess.Signal): void => { +const killProcessTree = ( + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + pid: number, + signal: ChildProcess.Signal, +): Effect.Effect<void, PlatformError.PlatformError> => { if (isWindows) { - try { - execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { - stdio: "ignore", - timeout: 5_000, - }); - } catch {} - - return; + return Effect.scoped( + Effect.gen(function* () { + const taskkill = yield* spawner.spawn( + ChildProcess.make("taskkill", ["/PID", String(pid), "/T", "/F"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ); + yield* taskkill.exitCode.pipe(Effect.timeout(Duration.seconds(5)), Effect.asVoid); + }), + ).pipe(Effect.ignore); } - try { - process.kill(-pid, signal); - return; - } catch {} + return Effect.sync(() => { + try { + process.kill(-pid, signal); + return; + } catch {} - try { - process.kill(pid, signal); - } catch {} + try { + process.kill(pid, signal); + } catch {} + }); }; const isWindows = process.platform === "win32"; const waitForExit = ( - childExit: Deferred.Deferred<ChildExit>, + childExit: Deferred.Deferred<Exit.Exit<ChildExit, PlatformError.PlatformError>>, timeoutMs: number, -): Effect.Effect<boolean> => +): Effect.Effect<boolean, PlatformError.PlatformError> => Deferred.await(childExit).pipe( + Effect.flatMap((result) => + Exit.isSuccess(result) ? Effect.succeed(true) : Effect.failCause(result.cause), + ), Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.isSome), ); -const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Effect<void> => +const runSupervisorRuntimeEffect = ( + config: SupervisorRuntimeConfig, +): Effect.Effect< + void, + PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> => Effect.scoped( Effect.gen(function* () { const childEnv = withoutSupervisorRuntimeEnv(); - const child = yield* Effect.sync(() => - spawn(config.command, config.args ?? [], { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fs = yield* FileSystem.FileSystem; + const shutdownRequest = yield* Deferred.make<ChildProcess.Signal>(); + const requestShutdown = (signal: ChildProcess.Signal) => + Deferred.doneUnsafe(shutdownRequest, Effect.succeed(signal)); + const onStdinEnd = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); + const onStdinClose = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); + const onSigInt = () => requestShutdown("SIGINT"); + const onSigTerm = () => requestShutdown("SIGTERM"); + + process.stdin.on("end", onStdinEnd); + process.stdin.on("close", onStdinClose); + process.on("SIGINT", onSigInt); + process.on("SIGTERM", onSigTerm); + process.stdin.resume(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + process.stdin.removeListener("end", onStdinEnd); + process.stdin.removeListener("close", onStdinClose); + process.removeListener("SIGINT", onSigInt); + process.removeListener("SIGTERM", onSigTerm); + }), + ); + + const child = yield* spawner.spawn( + ChildProcess.make(config.command, config.args ?? [], { cwd: process.cwd(), env: childEnv, - stdio: ["ignore", "pipe", "pipe"], + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", detached: !isWindows, }), ); - if (child.stdout != null) child.stdout.pipe(process.stdout); - if (child.stderr != null) child.stderr.pipe(process.stderr); - const childExit = yield* Deferred.make<ChildExit>(); - const shutdownRequest = yield* Deferred.make<ChildProcess.Signal>(); - const onChildExit = (code: number | null, signal: NodeJS.Signals | null) => { - Effect.runSync(Deferred.succeed(childExit, { code, signal })); - }; - child.once("exit", onChildExit); + yield* Stream.runForEach(child.stdout, (chunk) => writeChunk(process.stdout, chunk)).pipe( + Effect.forkChild, + Effect.ignore, + ); + yield* Stream.runForEach(child.stderr, (chunk) => writeChunk(process.stderr, chunk)).pipe( + Effect.forkChild, + Effect.ignore, + ); + + const childExit = yield* Deferred.make<Exit.Exit<ChildExit, PlatformError.PlatformError>>(); + yield* child.exitCode.pipe( + Effect.exit, + Effect.flatMap((result) => { + if (Exit.isSuccess(result)) { + return Deferred.succeed( + childExit, + Exit.succeed({ code: result.value, signal: null } satisfies ChildExit), + ); + } + + const signal = Option.getOrUndefined(childSignalFromCause(result.cause)); + return signal === undefined + ? Deferred.succeed(childExit, Exit.failCause(result.cause)) + : Deferred.succeed(childExit, Exit.succeed({ code: null, signal } satisfies ChildExit)); + }), + Effect.forkChild, + ); const ownerPid = typeof config.ownerPid === "number" ? config.ownerPid : undefined; const ownerAlive = () => { @@ -255,28 +329,8 @@ const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Eff return false; } }; - const requestShutdown = (signal: ChildProcess.Signal) => { - Effect.runSync(Deferred.succeed(shutdownRequest, signal)); - }; - - process.stdin.resume(); - const onStdinEnd = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); - const onStdinClose = () => requestShutdown(config.shutdownSignal ?? "SIGTERM"); - const onSigInt = () => requestShutdown("SIGINT"); - const onSigTerm = () => requestShutdown("SIGTERM"); - process.stdin.on("end", onStdinEnd); - process.stdin.on("close", onStdinClose); - process.on("SIGINT", onSigInt); - process.on("SIGTERM", onSigTerm); yield* Effect.addFinalizer(() => - Effect.sync(() => { - child.removeListener("exit", onChildExit); - process.stdin.removeListener("end", onStdinEnd); - process.stdin.removeListener("close", onStdinClose); - process.removeListener("SIGINT", onSigInt); - process.removeListener("SIGTERM", onSigTerm); - if (child.pid != null) killProcessTree(child.pid, "SIGKILL"); - }), + killProcessTree(spawner, child.pid, "SIGKILL").pipe(Effect.ignore), ); const ownerWatcher = yield* Effect.forkChild( @@ -291,47 +345,44 @@ const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Eff ); const killChildTree = (signal: ChildProcess.Signal) => - Effect.sync(() => { - if (child.pid != null) killProcessTree(child.pid, signal); - }); + killProcessTree(spawner, child.pid, signal); const runCleanupCommand = (action: RunCommandAction): Effect.Effect<void> => - Effect.callback<void>((resume) => { - const cleanupChild = spawn(action.executable, action.args, { - detached: !isWindows, - env: childEnv, - stdio: "ignore", - }); - const finish = () => resume(Effect.void); - cleanupChild.once("error", finish); - cleanupChild.once("exit", finish); - return Effect.sync(() => { - cleanupChild.removeListener("error", finish); - cleanupChild.removeListener("exit", finish); - if (cleanupChild.pid != null) killProcessTree(cleanupChild.pid, "SIGKILL"); - }); - }).pipe( - Effect.timeoutOption( - Duration.millis(action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS), - ), - Effect.asVoid, - Effect.catch(() => Effect.void), - ); + Effect.scoped( + Effect.gen(function* () { + const cleanupChild = yield* spawner.spawn( + ChildProcess.make(action.executable, action.args, { + detached: !isWindows, + env: childEnv, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ); + const exited = yield* cleanupChild.exitCode.pipe( + Effect.timeoutOption( + Duration.millis(action.timeoutMs ?? DEFAULT_CLEANUP_COMMAND_TIMEOUT_MS), + ), + ); + if (Option.isNone(exited)) { + yield* killProcessTree(spawner, cleanupChild.pid, "SIGKILL"); + } + }), + ).pipe(Effect.ignore); const runCleanup = Effect.gen(function* () { const removePathWithRetry = (action: RemovePathAction) => - Effect.try({ - try: () => { - rmSync(action.path, { - recursive: action.recursive ?? true, - force: action.force ?? true, - }); - }, - catch: (cause) => cause, - }).pipe( - Effect.retry(Schedule.spaced(Duration.millis(250)).pipe(Schedule.upTo({ times: 19 }))), - Effect.catch(() => Effect.void), - ); + fs + .remove(action.path, { + recursive: action.recursive ?? true, + force: action.force ?? true, + }) + .pipe( + Effect.retry( + Schedule.spaced(Duration.millis(250)).pipe(Schedule.upTo({ times: 19 })), + ), + Effect.ignore, + ); yield* Effect.all( [ Effect.forEach( @@ -371,27 +422,35 @@ const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Eff Effect.map((signal): SupervisorOutcome => ({ _tag: "ShutdownRequested", signal })), ), Deferred.await(childExit).pipe( - Effect.map((exit): SupervisorOutcome => ({ _tag: "ChildExited", exit })), + Effect.flatMap((result) => + Exit.isSuccess(result) + ? Effect.succeed({ + _tag: "ChildExited", + exit: result.value, + } satisfies SupervisorOutcome) + : Effect.failCause(result.cause), + ), ), ); - yield* Fiber.interrupt(ownerWatcher); - yield* Match.valueTags(outcome, { + return yield* Match.valueTags(outcome, { ShutdownRequested: ({ signal }) => Effect.gen(function* () { + yield* Fiber.interrupt(ownerWatcher).pipe(Effect.exit); yield* shutdown(signal); yield* runCleanup; - yield* Effect.sync(() => process.exit(0)); + return yield* Effect.sync(() => process.exit(0)); }), ChildExited: ({ exit: { code, signal } }) => Effect.gen(function* () { + yield* Fiber.interrupt(ownerWatcher).pipe(Effect.exit); if (!ownerAlive() || (config.cleanup?.length ?? 0) > 0) { yield* runCleanup; - yield* Effect.sync(() => process.exit(0)); + return yield* Effect.sync(() => process.exit(0)); } else if (signal != null) { - yield* Effect.sync(() => process.exit(1)); + return yield* Effect.sync(() => process.exit(1)); } else { - yield* Effect.sync(() => process.exit(code ?? 0)); + return yield* Effect.sync(() => process.exit(code ?? 0)); } }), }); @@ -401,7 +460,9 @@ const runSupervisorRuntimeEffect = (config: SupervisorRuntimeConfig): Effect.Eff export function runSupervisorRuntime(encodedConfig = process.argv[2]): void { if (encodedConfig == null) throw new Error("Missing supervisor config"); const config = parseSupervisorRuntimeConfig(encodedConfig); - void Effect.runPromise(runSupervisorRuntimeEffect(config)).catch(() => process.exit(1)); + void Effect.runPromise( + runSupervisorRuntimeEffect(config).pipe(Effect.provide(BunServices.layer)), + ).catch(() => process.exit(1)); } if (isMain) { diff --git a/packages/stack/scripts/sync-versions-from-dockerfile.ts b/packages/stack/scripts/sync-versions-from-dockerfile.ts index a35e4db24d..065cd0be33 100644 --- a/packages/stack/scripts/sync-versions-from-dockerfile.ts +++ b/packages/stack/scripts/sync-versions-from-dockerfile.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- This standalone Node maintenance script intentionally uses native filesystem and path APIs. +// oxlint-disable effecttsgo/async-function, effecttsgo/global-console -- This standalone script is a native Node CLI entrypoint. import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; diff --git a/packages/stack/src/ApiProxy.unit.test.ts b/packages/stack/src/ApiProxy.unit.test.ts index 2428816ec9..3020488410 100644 --- a/packages/stack/src/ApiProxy.unit.test.ts +++ b/packages/stack/src/ApiProxy.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as http from "node:http"; import { gzipSync } from "node:zlib"; diff --git a/packages/stack/src/BinaryResolver.integration.test.ts b/packages/stack/src/BinaryResolver.integration.test.ts index 3813a640ba..a02e71c325 100644 --- a/packages/stack/src/BinaryResolver.integration.test.ts +++ b/packages/stack/src/BinaryResolver.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-date-in-effect, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { zstdCompressSync } from "node:zlib"; diff --git a/packages/stack/src/BinaryResolver.ts b/packages/stack/src/BinaryResolver.ts index 016ae1d344..739b5e1ff6 100644 --- a/packages/stack/src/BinaryResolver.ts +++ b/packages/stack/src/BinaryResolver.ts @@ -213,10 +213,10 @@ const validateManifest = ( > => Effect.gen(function* () { if (typeof raw !== "object" || raw === null) { - return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest must be an object")); + return yield* manifestError(release.manifestUrl, "Manifest must be an object"); } if (!isSlimServiceManifest(raw)) { - return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest schema is invalid")); + return yield* manifestError(release.manifestUrl, "Manifest schema is invalid"); } const manifest = raw; if ( @@ -224,11 +224,9 @@ const validateManifest = ( manifest.version !== release.version || manifest.target !== release.target ) { - return yield* Effect.fail( - manifestError( - release.manifestUrl, - "Manifest service/version/target does not match release", - ), + return yield* manifestError( + release.manifestUrl, + "Manifest service/version/target does not match release", ); } if ( @@ -237,12 +235,13 @@ const validateManifest = ( !Array.isArray(manifest.cmd) || !manifest.cmd.every((value) => typeof value === "string") ) { - return yield* Effect.fail( - manifestError(release.manifestUrl, "Manifest entrypoint/cmd must be string arrays"), + return yield* manifestError( + release.manifestUrl, + "Manifest entrypoint/cmd must be string arrays", ); } if (manifest.entrypoint.length === 0 && manifest.cmd.length === 0) { - return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest has no command")); + return yield* manifestError(release.manifestUrl, "Manifest has no command"); } const runtimeRequires = manifest.runtime_requires ?? null; const commandPaths = [...manifest.entrypoint, ...manifest.cmd].filter( @@ -254,21 +253,17 @@ const validateManifest = ( entry === "..", ); if (commandPaths.some((entry) => hasTraversalSegment(entry))) { - return yield* Effect.fail( - manifestError(release.manifestUrl, "Manifest command path is unsafe"), - ); + return yield* manifestError(release.manifestUrl, "Manifest command path is unsafe"); } const osFloor = manifest.os_floor; if (osFloor !== null && typeof osFloor !== "object") { - return yield* Effect.fail(manifestError(release.manifestUrl, "Manifest os_floor is invalid")); + return yield* manifestError(release.manifestUrl, "Manifest os_floor is invalid"); } if (osFloor !== null && osFloor.kind !== "macos" && osFloor.kind !== "glibc") { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target: release.target, - detail: `Unsupported manifest host kind ${osFloor.kind}`, - }), - ); + return yield* new BinaryHostCompatibilityError({ + target: release.target, + detail: `Unsupported manifest host kind ${osFloor.kind}`, + }); } const hostCompatibility: HostCompatibilityRequirement = { runtimeRequires, @@ -296,20 +291,16 @@ const validateHostCompatibility = ( requirement.runtimeRequires === "glibc" || requirement.osFloor?.kind === "glibc"; if (requirement.osFloor?.kind === "macos" && platform.os !== "darwin") { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: "Manifest requires macOS", - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: "Manifest requires macOS", + }); } if (requiresGlibc && platform.os !== "linux") { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: "Manifest requires Linux/glibc", - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: "Manifest requires Linux/glibc", + }); } const floor = requirement.osFloor?.floor; if (requiresGlibc) { @@ -334,30 +325,24 @@ const validateHostCompatibility = ( } }); if (typeof host !== "string" || host.trim().length === 0) { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: "Unable to determine host glibc version", - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: "Unable to determine host glibc version", + }); } if (floor !== null && floor !== undefined) { const comparison = compareVersions(host, floor); if (comparison === undefined) { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: `Host glibc ${host} or manifest floor ${floor} is not a dotted numeric version`, - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: `Host glibc ${host} or manifest floor ${floor} is not a dotted numeric version`, + }); } if (comparison < 0) { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: `Host glibc ${host} is below manifest floor ${floor}`, - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: `Host glibc ${host} is below manifest floor ${floor}`, + }); } } } @@ -378,29 +363,23 @@ const validateHostCompatibility = ( ); const hostVersion = host.trim().split(/\s+/)[0] ?? ""; if (hostVersion.length === 0) { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: "Unable to determine macOS version", - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: "Unable to determine macOS version", + }); } const comparison = compareVersions(hostVersion, floor); if (comparison === undefined) { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: `Host macOS ${hostVersion} or manifest floor ${floor} is not a dotted numeric version`, - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: `Host macOS ${hostVersion} or manifest floor ${floor} is not a dotted numeric version`, + }); } if (comparison < 0) { - return yield* Effect.fail( - new BinaryHostCompatibilityError({ - target, - detail: `Host macOS ${hostVersion} is below manifest floor ${floor}`, - }), - ); + return yield* new BinaryHostCompatibilityError({ + target, + detail: `Host macOS ${hostVersion} is below manifest floor ${floor}`, + }); } } }); @@ -530,6 +509,8 @@ export class BinaryResolver extends Context.Service< if (Option.isNone(marker)) return false; const parsed = yield* Effect.sync(() => { try { + // The cache marker is intentionally decoded as unknown before manifest validation. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Cache metadata is an untyped compatibility boundary. const value: unknown = JSON.parse(marker.value); return value; } catch { @@ -568,12 +549,10 @@ export class BinaryResolver extends Context.Service< const platform = yield* detectPlatform; const release = nativeReleaseForService(spec.service, spec.version, platform); if (release === undefined) { - return yield* Effect.fail( - new BinaryNotFoundError({ - service: spec.service, - platform: `${platform.os}-${platform.arch}`, - }), - ); + return yield* new BinaryNotFoundError({ + service: spec.service, + platform: `${platform.os}-${platform.arch}`, + }); } const info: AssetInfo = { service: spec.service, @@ -603,6 +582,8 @@ export class BinaryResolver extends Context.Service< Option.match(info.mtime, { onNone: () => Effect.void, onSome: (modifiedAt) => + // Staging cleanup compares filesystem mtimes to the host wall clock. + // oxlint-disable-next-line effecttsgo/global-date -- Native filesystem freshness boundary. Date.now() - modifiedAt.getTime() >= STALE_PREPARATION_ENTRY_AGE_MS ? fs.remove(stagingPath, { recursive: true, force: true }) : Effect.void, @@ -638,12 +619,10 @@ export class BinaryResolver extends Context.Service< relative === ".." || relative.startsWith(`..${path.sep}`) ) { - return yield* Effect.fail( - new BinaryRuntimeError({ - path: candidate, - detail: `Extracted path resolves outside private staging: ${entry}`, - }), - ); + return yield* new BinaryRuntimeError({ + path: candidate, + detail: `Extracted path resolves outside private staging: ${entry}`, + }); } } }); @@ -668,6 +647,8 @@ export class BinaryResolver extends Context.Service< ); const hostCompatibility = yield* Effect.try({ try: () => { + // The release manifest is validated by validateManifest immediately after parsing. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- External registry payload is intentionally unknown here. const parsed: unknown = JSON.parse(manifestText); return parsed; }, @@ -702,8 +683,9 @@ export class BinaryResolver extends Context.Service< ); const expected = checksumForArchive(checksumText, `${release.assetName}.tar.zst`); if (expected === undefined) { - return yield* Effect.fail( - manifestError(release.checksumUrl, "SHA256SUMS has no entry for the archive"), + return yield* manifestError( + release.checksumUrl, + "SHA256SUMS has no entry for the archive", ); } yield* verifyChecksum(tarball, expected, release.checksumUrl); @@ -715,13 +697,12 @@ export class BinaryResolver extends Context.Service< const members = yield* spawner .string(ChildProcess.make("tar", ["-tf", archivePath])) .pipe( - Effect.catch((cause) => - Effect.fail( + Effect.mapError( + (cause) => new DownloadError({ url: release.downloadUrl, cause, }), - ), ), ); const unsafeMember = members @@ -729,12 +710,10 @@ export class BinaryResolver extends Context.Service< .map((member) => member.trim()) .find(isUnsafeArchiveMember); if (unsafeMember !== undefined) { - return yield* Effect.fail( - new DownloadError({ - url: release.downloadUrl, - cause: new Error(`archive member is unsafe: ${unsafeMember}`), - }), - ); + return yield* new DownloadError({ + url: release.downloadUrl, + cause: new Error(`archive member is unsafe: ${unsafeMember}`), + }); } const exitCode = yield* spawner @@ -745,12 +724,10 @@ export class BinaryResolver extends Context.Service< ), ); if (exitCode !== 0) { - return yield* Effect.fail( - new DownloadError({ - url: release.downloadUrl, - cause: new Error(`extraction exited with code ${exitCode}`), - }), - ); + return yield* new DownloadError({ + url: release.downloadUrl, + cause: new Error(`extraction exited with code ${exitCode}`), + }); } yield* validateExtractedTree(destination); @@ -769,12 +746,10 @@ export class BinaryResolver extends Context.Service< ), ); if (exitCode !== 0) { - return yield* Effect.fail( - new BinaryRuntimeError({ - path: destination, - detail: `${name} exited with code ${exitCode}`, - }), - ); + return yield* new BinaryRuntimeError({ + path: destination, + detail: `${name} exited with code ${exitCode}`, + }); } }); @@ -815,12 +790,10 @@ export class BinaryResolver extends Context.Service< fs.exists(path.join(destination, entry)), ).pipe(Effect.map((exists) => requiredPaths.filter((_entry, index) => !exists[index]))); if (missing.length > 0) { - return yield* Effect.fail( - new BinaryRuntimeError({ - path: destination, - detail: `Manifest runtime paths are missing: ${missing.join(", ")}`, - }), - ); + return yield* new BinaryRuntimeError({ + path: destination, + detail: `Manifest runtime paths are missing: ${missing.join(", ")}`, + }); } return hostCompatibility; }); @@ -853,6 +826,8 @@ export class BinaryResolver extends Context.Service< yield* fs.writeFile( path.join(stagingDir, CACHE_COMPLETE_MARKER), new TextEncoder().encode( + // This marker is a stable cache interchange format, not an in-memory schema value. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Cache metadata is written as JSON. JSON.stringify({ provider: release.provider, service: spec.service, @@ -911,7 +886,7 @@ export class BinaryResolver extends Context.Service< if (yield* isCompleteCache(cacheDir, release, info, platform)) { return { path: cacheDir, downloaded: false } satisfies ResolveBinaryResult; } - return yield* Effect.fail(retryFailure); + return yield* retryFailure; }), ); }).pipe( diff --git a/packages/stack/src/ContainerRuntime.ts b/packages/stack/src/ContainerRuntime.ts index 3f1ff135ce..5382caec0f 100644 --- a/packages/stack/src/ContainerRuntime.ts +++ b/packages/stack/src/ContainerRuntime.ts @@ -52,12 +52,10 @@ export const selectStackRuntimeForPlatform = ( if (nativeTargetForPlatform(platform) !== undefined) { return { mode: "native", containerRuntime: null }; } - return yield* Effect.fail( - new StackBuildError({ - detail: `Native mode is unavailable on ${platform.os}-${platform.arch}. Use a supported Linux or Apple silicon macOS host, or install and start Docker or Podman.`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `Native mode is unavailable on ${platform.os}-${platform.arch}. Use a supported Linux or Apple silicon macOS host, or install and start Docker or Podman.`, + reason: "invalid_config", + }); } const runtimes = ["docker", "podman"] as const satisfies ReadonlyArray<ContainerRuntime>; @@ -73,23 +71,19 @@ export const selectStackRuntimeForPlatform = ( } if (requestedMode === "docker") { - return yield* Effect.fail( - new StackBuildError({ - detail: "Docker mode requires a usable Docker or Podman runtime", - reason: "docker_not_running", - }), - ); + return yield* new StackBuildError({ + detail: "Docker mode requires a usable Docker or Podman runtime", + reason: "docker_not_running", + }); } if (nativeTargetForPlatform(platform) !== undefined) { return { mode: "native", containerRuntime: null }; } - return yield* Effect.fail( - new StackBuildError({ - detail: `No usable Docker or Podman runtime was found, and native mode is unavailable on ${platform.os}-${platform.arch}. Install and start Docker or Podman.`, - reason: "docker_not_running", - }), - ); + return yield* new StackBuildError({ + detail: `No usable Docker or Podman runtime was found, and native mode is unavailable on ${platform.os}-${platform.arch}. Install and start Docker or Podman.`, + reason: "docker_not_running", + }); }); export const selectStackRuntime = ( diff --git a/packages/stack/src/ControlHttpReader.ts b/packages/stack/src/ControlHttpReader.ts index 76d09bf0e0..17fb106f9f 100644 --- a/packages/stack/src/ControlHttpReader.ts +++ b/packages/stack/src/ControlHttpReader.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- Node IncomingMessage exposes the listener lifecycle required by this control-plane boundary. import * as Http from "node:http"; import { Effect } from "effect"; import { @@ -40,7 +41,7 @@ const readError = ( /** Protocol-aware owner reader shared by the Node and Bun control transports. */ export const readControlOwner: ControlOwnerReader = (endpoint) => - Effect.callback<unknown, unknown>((resume) => { + Effect.callback<unknown, ControlTransportError | ControlProtocolError>((resume) => { let response: Http.IncomingMessage | undefined; let onData: ((chunk: string) => void) | undefined; let onEnd: (() => void) | undefined; @@ -50,14 +51,17 @@ export const readControlOwner: ControlOwnerReader = (endpoint) => let settled = false; let cleanup = () => {}; let dispose = () => {}; - const finish = (effect: Effect.Effect<unknown, unknown>, shouldDispose = false) => { + const finish = ( + effect: Effect.Effect<unknown, ControlTransportError | ControlProtocolError>, + shouldDispose = false, + ) => { if (settled) return; settled = true; cleanup(); if (shouldDispose) dispose(); resume(effect); }; - const onRequestError = (cause: Error) => finish(Effect.fail(cause), true); + const onRequestError = (cause: Error) => finish(Effect.fail(readError(endpoint, cause)), true); const request = Http.request( { host: endpoint.hostname, @@ -80,7 +84,10 @@ export const readControlOwner: ControlOwnerReader = (endpoint) => if (bodyBytes > MAX_CONTROL_RESPONSE_BYTES) { finish( Effect.fail( - new Error(`Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`), + readError( + endpoint, + new Error(`Control status response exceeded ${MAX_CONTROL_RESPONSE_BYTES} bytes`), + ), ), true, ); @@ -93,7 +100,10 @@ export const readControlOwner: ControlOwnerReader = (endpoint) => if ((incoming.statusCode ?? 500) < 200 || (incoming.statusCode ?? 500) >= 300) { finish( Effect.fail( - new Error(`Control status request returned ${incoming.statusCode ?? 500}`), + readError( + endpoint, + new Error(`Control status request returned ${incoming.statusCode ?? 500}`), + ), ), true, ); @@ -102,16 +112,21 @@ export const readControlOwner: ControlOwnerReader = (endpoint) => try { finish(Effect.succeed(JSON.parse(body))); } catch (cause) { - finish(Effect.fail(cause), true); + finish(Effect.fail(readError(endpoint, cause)), true); } }; - onResponseError = (cause) => finish(Effect.fail(cause), true); + onResponseError = (cause) => finish(Effect.fail(readError(endpoint, cause)), true); onResponseAborted = () => { responseAborted = true; }; onResponseClose = () => { if (responseAborted || !ended) { - finish(Effect.fail(new Error("Control status response closed before end")), true); + finish( + Effect.fail( + readError(endpoint, new Error("Control status response closed before end")), + ), + true, + ); } }; incoming.setEncoding("utf8"); @@ -154,7 +169,6 @@ export const readControlOwner: ControlOwnerReader = (endpoint) => }).pipe( Effect.timeoutOrElse({ duration: 500, - orElse: () => Effect.fail(new Error("Control status request timed out")), + orElse: () => Effect.fail(readError(endpoint, new Error("Control status request timed out"))), }), - Effect.mapError((cause) => readError(endpoint, cause)), ); diff --git a/packages/stack/src/HttpTransportClient.integration.test.ts b/packages/stack/src/HttpTransportClient.integration.test.ts index 6d1fcad6b3..21e5e6b5ab 100644 --- a/packages/stack/src/HttpTransportClient.integration.test.ts +++ b/packages/stack/src/HttpTransportClient.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { Effect, Fiber, ManagedRuntime } from "effect"; import type { Socket } from "node:net"; import { createServer, type Server } from "node:http"; diff --git a/packages/stack/src/HttpTransportClient.ts b/packages/stack/src/HttpTransportClient.ts index 37b8c72cf0..708e2f6d79 100644 --- a/packages/stack/src/HttpTransportClient.ts +++ b/packages/stack/src/HttpTransportClient.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-fetch-in-effect -- This service is the native fetch transport boundary and maps failures into HttpTransportClientError. import { Context, Data, Effect, Layer } from "effect"; import { CONTROL_STATUS_PATH, @@ -102,6 +103,8 @@ const makeHttpControlTransport = ( Effect.suspend(() => transport.request(endpoint, CONTROL_STOP_PATH, { method: "POST", + // Control requests cross the HTTP protocol boundary as JSON. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native HTTP transport owns this wire encoding. body: JSON.stringify(request), headers: { "content-type": "application/json", connection: "close" }, signal: AbortSignal.timeout(CONTROL_REQUEST_TIMEOUT_MS), diff --git a/packages/stack/src/JwtGenerator.ts b/packages/stack/src/JwtGenerator.ts index ae4566cfd3..978697d02a 100644 --- a/packages/stack/src/JwtGenerator.ts +++ b/packages/stack/src/JwtGenerator.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-date -- JWT generation is a synchronous config-boundary helper that stamps wall-clock claims. import { createHmac } from "node:crypto"; // Hardcoded opaque key defaults matching Go CLI (pkg/config/apikeys.go:19-20). diff --git a/packages/stack/src/JwtGenerator.unit.test.ts b/packages/stack/src/JwtGenerator.unit.test.ts index 9d03590bb1..5d74642660 100644 --- a/packages/stack/src/JwtGenerator.unit.test.ts +++ b/packages/stack/src/JwtGenerator.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-date -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { createHmac } from "node:crypto"; import { describe, expect, it } from "vitest"; import { defaultJwtSecret, generateJwks, generateJwt } from "./JwtGenerator.ts"; diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 4811af7bfb..e5e65a41ca 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -25,6 +25,7 @@ import { SubscriptionRef, } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { FetchHttpClient } from "effect/unstable/http"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { cleanupLocalStackResources } from "./cleanup.ts"; import { @@ -260,7 +261,15 @@ export const localStackLayer = ( if (index === -1 || current[index]?.status === "Downloading") return current; return current.map((entry, entryIndex) => entryIndex === index - ? new StackServiceState({ ...entry, status: "Downloading" }) + ? new StackServiceState({ + name: entry.name, + status: "Downloading", + pid: entry.pid, + exitCode: entry.exitCode, + restartCount: entry.restartCount, + startedAt: entry.startedAt, + error: entry.error, + }) : entry, ); }); @@ -280,20 +289,20 @@ export const localStackLayer = ( const syncProjectedStates = ( orchestrator: Orchestrator["Service"], serviceProjection: StackServiceProjectionCatalog, - ) => + ): Effect.Effect<void> => Effect.gen(function* () { - const rawStates = yield* orchestrator.getAllStates(); + const rawStates = yield* orchestrator.getAllStates; yield* Effect.forEach(projectStackStates(rawStates, serviceProjection), updateState, { discard: true, }); - }).pipe(projectionLock.withPermit); + }).pipe((effect) => projectionLock.withPermit(effect)); const requireKnownService = (name: string) => Effect.gen(function* () { const currentStates = SubscriptionRef.getUnsafe(stateRef); const match = currentStates.find((state) => state.name === name); if (match === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } return match; }); @@ -304,7 +313,7 @@ export const localStackLayer = ( yield* requireKnownService(name); const service = SERVICE_NAMES.find((candidate) => candidate === name); if (service === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } return service; }); @@ -469,82 +478,82 @@ export const localStackLayer = ( }), ); - const ensureRuntime = Effect.uninterruptibleMask((restore) => - Effect.suspend(() => { - if (disposed || disposing) { - return Effect.fail( - new StackBuildError({ - detail: "Cannot ensure stack runtime after stack disposal has begun", - }), - ); - } - if (runtimeState !== undefined) { - return Effect.succeed(runtimeState); - } - if (runtimeDeferred !== undefined) return restore(Deferred.await(runtimeDeferred)); + const ensureRuntime: Effect.Effect<RuntimeState, StackBuildError> = + Effect.uninterruptibleMask((restore) => + Effect.suspend(() => { + if (disposed || disposing) { + return Effect.fail( + new StackBuildError({ + detail: "Cannot ensure stack runtime after stack disposal has begun", + }), + ); + } + if (runtimeState !== undefined) { + return Effect.succeed(runtimeState); + } + if (runtimeDeferred !== undefined) return restore(Deferred.await(runtimeDeferred)); - const deferred = Deferred.makeUnsafe<RuntimeState, StackBuildError>(); - runtimeDeferred = deferred; + const deferred = Deferred.makeUnsafe<RuntimeState, StackBuildError>(); + runtimeDeferred = deferred; - const effect = Effect.gen(function* () { - const prepared = yield* ensurePlanned; - const { graph, serviceProjection, cleanupTargets } = yield* builder - .build(config, prepared) - .pipe( - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Scope.Scope, scope), + const effect = Effect.gen(function* () { + const prepared = yield* ensurePlanned; + const { graph, serviceProjection, cleanupTargets } = yield* builder + .build(config, prepared) + .pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Scope.Scope, scope), + ); + const graphServices = new Set(graph.startOrder.map((definition) => definition.name)); + const missingEnabledService = enabledServices.find( + (service) => !graphServices.has(service), ); - const graphServices = new Set(graph.startOrder.map((definition) => definition.name)); - const missingEnabledService = enabledServices.find( - (service) => !graphServices.has(service), - ); - if (missingEnabledService !== undefined) { - return yield* Effect.fail( - new StackBuildError({ + if (missingEnabledService !== undefined) { + return yield* new StackBuildError({ detail: `Prepared graph does not contain enabled service ${missingEnabledService}`, - }), + }); + } + exactCleanupTargets = cleanupTargets; + + const orchLayer = Orchestrator.layer(graph).pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(Layer.succeed(LogBuffer, logBuffer)), + Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), ); - } - exactCleanupTargets = cleanupTargets; + const orchServices = yield* Layer.buildWithScope(orchLayer, scope); + const orchestrator = Context.get(orchServices, Orchestrator); - const orchLayer = Orchestrator.layer(graph).pipe( - Layer.provide(Layer.succeed(LogBuffer, logBuffer)), - Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), - ); - const orchServices = yield* Layer.buildWithScope(orchLayer, scope); - const orchestrator = Context.get(orchServices, Orchestrator); + yield* syncProjectedStates(orchestrator, serviceProjection); + yield* orchestrator.allStateChanges.pipe( + Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), + Effect.ignore, + Effect.forkIn(scope), + ); - yield* syncProjectedStates(orchestrator, serviceProjection); - yield* orchestrator.allStateChanges().pipe( - Stream.runForEach(() => syncProjectedStates(orchestrator, serviceProjection)), - Effect.ignore, - Effect.forkIn(scope), + return { + orchestrator, + graph, + serviceProjection, + } satisfies RuntimeState; + }).pipe( + Effect.tap((value) => + Effect.sync(() => { + runtimeState = value; + }), + ), + Effect.ensuring( + Effect.sync(() => { + runtimeDeferred = undefined; + }), + ), ); - return { - orchestrator, - graph, - serviceProjection, - } satisfies RuntimeState; - }).pipe( - Effect.tap((value) => - Effect.sync(() => { - runtimeState = value; - }), - ), - Effect.ensuring( - Effect.sync(() => { - runtimeDeferred = undefined; - }), - ), - ); - - return Effect.gen(function* () { - yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); - return yield* restore(Deferred.await(deferred)); - }); - }), - ); + return Effect.gen(function* () { + yield* Effect.forkIn(effect.pipe(Deferred.into(deferred)), preparationScope); + return yield* restore(Deferred.await(deferred)); + }); + }), + ); let disposed = false; let disposing = false; @@ -598,7 +607,7 @@ export const localStackLayer = ( Effect.gen(function* () { const currentEdgeRuntime = yield* Ref.get(edgeRuntimeConfigRef); if (currentEdgeRuntime === false || opts.edgeRuntime.enabled === false) { - return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } return { @@ -623,7 +632,8 @@ export const localStackLayer = ( (previous, current) => [current, changedStatesBetween(previous, current)], ), ); - const withLifecycleLock = lifecycleLock.withPermit; + const withLifecycleLock = <A, E, R>(effect: Effect.Effect<A, E, R>) => + lifecycleLock.withPermit(effect); const syncRuntimeProjectedStates = (runtime: RuntimeState) => syncProjectedStates(runtime.orchestrator, runtime.serviceProjection); const serviceStartOptions = { @@ -642,7 +652,10 @@ export const localStackLayer = ( const beginStartTargets = ( root: ServiceName, allowExplicitlyStopped: ReadonlySet<ServiceName>, - ) => + ): Effect.Effect< + { readonly runtime: RuntimeState; readonly targets: ReadonlyArray<ServiceName> }, + StackBuildError | ServiceReadyError + > => Effect.gen(function* () { const runtime = yield* ensureRuntime; const targets = activationTargetsForService(enabledServices, root); @@ -666,11 +679,9 @@ export const localStackLayer = ( publicDependency !== undefined && !allowExplicitlyStopped.has(publicDependency) ) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Cannot activate ${root} because dependency ${dependency} was explicitly stopped`, - }), - ); + return yield* new StackBuildError({ + detail: `Cannot activate ${root} because dependency ${dependency} was explicitly stopped`, + }); } } @@ -691,7 +702,7 @@ export const localStackLayer = ( }: { readonly runtime: RuntimeState; readonly targets: ReadonlyArray<ServiceName>; - }) => + }): Effect.Effect<void, StackBuildError | ServiceReadyError> => Effect.gen(function* () { yield* Effect.forEach( targets, @@ -707,7 +718,17 @@ export const localStackLayer = ( ); yield* syncRuntimeProjectedStates(runtime); }); - const inspectStartedTargets = (root: ServiceName) => + const inspectStartedTargets = ( + root: ServiceName, + ): Effect.Effect< + | { + readonly runtime: RuntimeState; + readonly targets: ReadonlyArray<ServiceName>; + readonly ready: boolean; + } + | undefined, + StackBuildError + > => Effect.gen(function* () { const runtime = yield* ensureRuntime; const targets = activationTargetsForService(enabledServices, root); @@ -732,13 +753,15 @@ export const localStackLayer = ( ), }; }); - const requireRunningPhase = Effect.gen(function* () { - const phase = yield* Ref.get(phaseRef); - if (phase !== "running") { - return yield* Effect.fail(new StackNotRunningError({ phase })); - } - }); - const requireMutable = (operation: string) => + const requireRunningPhase: Effect.Effect<void, StackNotRunningError> = Effect.gen( + function* () { + const phase = yield* Ref.get(phaseRef); + if (phase !== "running") { + return yield* new StackNotRunningError({ phase }); + } + }, + ); + const requireMutable = (operation: string): Effect.Effect<void, StackBuildError> => Effect.suspend(() => disposed || disposing ? Effect.fail( @@ -774,7 +797,7 @@ export const localStackLayer = ( yield* Scope.close(preparationScope, Exit.void); yield* cleanupLocalStackResources({ stop: () => - runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop(), + runtimeState === undefined ? Effect.void : runtimeState.orchestrator.stop, cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, config, }).pipe( @@ -790,7 +813,7 @@ export const localStackLayer = ( Effect.uninterruptible, ); - const withReadinessPolicy = <A, E, R>( + const withReadinessPolicy = <A, E extends Error, R>( effect: Effect.Effect<A, E, R>, target: string, readyOptions?: ReadyOptions, @@ -823,10 +846,10 @@ export const localStackLayer = ( ? Effect.succeed(error) : attachReadinessDiagnostics( error, - runtimeState.orchestrator.getAllStates(), + runtimeState.orchestrator.getAllStates, logBuffer.historyAll(READINESS_DIAGNOSTIC_LOG_LIMIT), ); - const cleanupOnReadinessFailure = <A, E, R>( + const cleanupOnReadinessFailure = <A, E extends Error, R>( effect: Effect.Effect<A, E | StackReadinessError, R>, ): Effect.Effect<A, E | StackReadinessError, R> => effect.pipe( @@ -940,9 +963,9 @@ export const localStackLayer = ( yield* requireMutable("start"); serviceStartupBegan = true; yield* runtime.orchestrator.start(serviceStartOptions); - yield* runtime.orchestrator - .waitAllReady() - .pipe((effect) => withReadinessPolicy(effect, "stack")); + yield* runtime.orchestrator.waitAllReady.pipe((effect) => + withReadinessPolicy(effect, "stack"), + ); yield* syncRuntimeProjectedStates(runtime); } yield* requireMutable("start"); @@ -964,7 +987,7 @@ export const localStackLayer = ( return; } yield* Ref.set(phaseRef, "stopping"); - yield* runtimeState.orchestrator.stop(); + yield* runtimeState.orchestrator.stop; yield* Ref.set(phaseRef, "stopped"); }).pipe(withLifecycleLock), dispose: disposeOnce, @@ -1050,7 +1073,7 @@ export const localStackLayer = ( yield* requireRunningPhase; yield* requireKnownService("edge-runtime"); if (opts.edgeRuntime.enabled === false) { - return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } const requestedBundle = opts.functions === undefined @@ -1076,7 +1099,7 @@ export const localStackLayer = ( ); if (edgeRuntimeDef === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); + return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } yield* configureFunctions(nextConfig, nextBundle); @@ -1109,7 +1132,7 @@ export const localStackLayer = ( const currentStates = SubscriptionRef.getUnsafe(stateRef); const match = currentStates.find((state) => state.name === name); if (match === undefined) { - return yield* Effect.fail(new ServiceNotFoundError({ name })); + return yield* new ServiceNotFoundError({ name }); } return match; }), @@ -1124,11 +1147,9 @@ export const localStackLayer = ( Effect.gen(function* () { const phase = yield* Ref.get(phaseRef); if (phase !== "running") { - return yield* Effect.fail( - new StackBuildError({ - detail: `Cannot wait for service ${name} while the stack is ${phase}`, - }), - ); + return yield* new StackBuildError({ + detail: `Cannot wait for service ${name} while the stack is ${phase}`, + }); } yield* requireKnownServiceName(name); const runtime = yield* ensureRuntime; @@ -1141,25 +1162,23 @@ export const localStackLayer = ( Effect.gen(function* () { const phase = yield* Ref.get(phaseRef); if (phase !== "running") { - return yield* Effect.fail( - new StackBuildError({ - detail: `Cannot wait for stack readiness while the stack is ${phase}`, - }), - ); + return yield* new StackBuildError({ + detail: `Cannot wait for stack readiness while the stack is ${phase}`, + }); } const runtime = yield* ensureRuntime; - yield* runtime.orchestrator - .waitAllReady() - .pipe((effect) => withReadinessPolicy(effect, "stack", opts)); + yield* runtime.orchestrator.waitAllReady.pipe((effect) => + withReadinessPolicy(effect, "stack", opts), + ); yield* syncRuntimeProjectedStates(runtime); }).pipe(cleanupOnReadinessFailure), subscribeLogs: (name) => logBuffer.subscribe(name), subscribeAllLogs: (services) => services === undefined || services.length === 0 - ? logBuffer.subscribeAll() - : logBuffer - .subscribeAll() - .pipe(Stream.filter((entry) => services.includes(entry.service))), + ? logBuffer.subscribeAll + : logBuffer.subscribeAll.pipe( + Stream.filter((entry) => services.includes(entry.service)), + ), logHistory: (name, limit) => logBuffer.history(name, limit), logHistoryAll: (limit, services) => logBuffer.historyAll(limit, services), } satisfies StackService; diff --git a/packages/stack/src/PortAllocator.integration.test.ts b/packages/stack/src/PortAllocator.integration.test.ts index 2297e43604..145408cb4e 100644 --- a/packages/stack/src/PortAllocator.integration.test.ts +++ b/packages/stack/src/PortAllocator.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-error-in-effect-failure, effecttsgo/global-timers-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { spawn } from "node:child_process"; import { once } from "node:events"; import { createServer, type Server } from "node:net"; diff --git a/packages/stack/src/PortAllocator.ts b/packages/stack/src/PortAllocator.ts index 38bde6b75e..ba736bc056 100644 --- a/packages/stack/src/PortAllocator.ts +++ b/packages/stack/src/PortAllocator.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. +// oxlint-disable effecttsgo/process-env -- Port claim names intentionally include the host user namespace. import { randomUUID } from "node:crypto"; import { createServer, type Server } from "node:net"; import { tmpdir } from "node:os"; @@ -141,7 +143,7 @@ const readClaimSnapshot = ( .readFileString(path) .pipe( Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), + isNotFound(error) ? Effect.void : Effect.fail(error), ), ); if (contents === undefined) return undefined; @@ -149,7 +151,7 @@ const readClaimSnapshot = ( .stat(path) .pipe( Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), + isNotFound(error) ? Effect.void : Effect.fail(error), ), ); if (info === undefined) return undefined; @@ -161,6 +163,8 @@ const claimIsStale = (snapshot: ClaimSnapshot): boolean => { if (snapshot.record !== undefined) return !isProcessAlive(snapshot.record.pid); return ( Option.isSome(snapshot.info.mtime) && + // Port claim staleness compares native filesystem mtimes to the host wall clock. + // oxlint-disable-next-line effecttsgo/global-date -- Native filesystem coordination boundary. Date.now() - snapshot.info.mtime.value.getTime() > CLAIM_STALE_AFTER_MS ); }; @@ -258,6 +262,8 @@ const acquirePortClaimInternal = ( yield* fs.makeDirectory(root, { recursive: true }); const path = claimPath(port, root); const token = randomUUID(); + // Port claim files are a small native coordination marker shared across processes. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- The marker format is intentionally JSON. const contents = JSON.stringify({ pid: process.pid, token }); for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt += 1) { @@ -281,7 +287,7 @@ const acquirePortClaimInternal = ( const failure = Cause.findErrorOption(openedExit.cause); if (Option.isNone(failure)) return yield* Effect.failCause(openedExit.cause); if (!isAlreadyExists(failure.value)) { - return yield* Effect.fail(failure.value); + return yield* failure.value; } const inspection = yield* inspectClaim(path); if (inspection === undefined) continue; @@ -591,10 +597,10 @@ const reserveRandomPort = ( return yield* reserveRandomPort(exclude, field, claims, fs, root, attempt + 1); } if (Option.isSome(failure) && failure.value instanceof PlatformError) { - return yield* Effect.fail(portAllocationFromCause(bound.port, failure.value)); + return yield* portAllocationFromCause(bound.port, failure.value); } if (Option.isSome(failure) && failure.value instanceof PortAllocationError) { - return yield* Effect.fail(failure.value); + return yield* failure.value; } return yield* Effect.failCause( Cause.map(claimExit.cause, (error) => @@ -618,7 +624,7 @@ const withPortField = (field: PortField, error: PortAllocationError): PortAlloca const decodePortSet = ( partial: Partial<Record<PortField, number>>, ): Effect.Effect<PortSet, PortAllocationError> => - Schema.decodeUnknownEffect(PortSetSchema)(partial).pipe( + Schema.decodeEffect(PortSetSchema)(partial).pipe( Effect.mapError( (cause) => new PortAllocationError({ diff --git a/packages/stack/src/PortCatalog.ts b/packages/stack/src/PortCatalog.ts index 8327edc899..fe879a414e 100644 --- a/packages/stack/src/PortCatalog.ts +++ b/packages/stack/src/PortCatalog.ts @@ -181,49 +181,49 @@ export const DEFAULT_PORTS: PortSet = { }; export const AllocatedPortsSchema = Schema.Struct({ - apiPort: Schema.Number, - dbPort: Schema.Number, - authPort: Schema.Number, - postgrestPort: Schema.Number, - postgrestAdminPort: Schema.Number, - edgeRuntimePort: Schema.Number, - edgeRuntimeInspectorPort: Schema.Number, - realtimePort: Schema.Number, - storagePort: Schema.Number, - imgproxyPort: Schema.Number, - mailpitPort: Schema.Number, - mailpitSmtpPort: Schema.Number, - mailpitPop3Port: Schema.Number, - pgmetaPort: Schema.Number, - studioPort: Schema.Number, - analyticsPort: Schema.Number, - poolerPort: Schema.Number, - poolerApiPort: Schema.Number, + apiPort: Schema.Finite, + dbPort: Schema.Finite, + authPort: Schema.Finite, + postgrestPort: Schema.Finite, + postgrestAdminPort: Schema.Finite, + edgeRuntimePort: Schema.Finite, + edgeRuntimeInspectorPort: Schema.Finite, + realtimePort: Schema.Finite, + storagePort: Schema.Finite, + imgproxyPort: Schema.Finite, + mailpitPort: Schema.Finite, + mailpitSmtpPort: Schema.Finite, + mailpitPop3Port: Schema.Finite, + pgmetaPort: Schema.Finite, + studioPort: Schema.Finite, + analyticsPort: Schema.Finite, + poolerPort: Schema.Finite, + poolerApiPort: Schema.Finite, }); export const PortSetSchema = Schema.Struct({ - apiPort: Schema.optionalKey(Schema.Number), - dbPort: Schema.optionalKey(Schema.Number), - authPort: Schema.optionalKey(Schema.Number), - postgrestPort: Schema.optionalKey(Schema.Number), - postgrestAdminPort: Schema.optionalKey(Schema.Number), - edgeRuntimePort: Schema.optionalKey(Schema.Number), - edgeRuntimeInspectorPort: Schema.optionalKey(Schema.Number), - realtimePort: Schema.optionalKey(Schema.Number), - storagePort: Schema.optionalKey(Schema.Number), - imgproxyPort: Schema.optionalKey(Schema.Number), - mailpitPort: Schema.optionalKey(Schema.Number), - mailpitSmtpPort: Schema.optionalKey(Schema.Number), - mailpitPop3Port: Schema.optionalKey(Schema.Number), - pgmetaPort: Schema.optionalKey(Schema.Number), - studioPort: Schema.optionalKey(Schema.Number), - analyticsPort: Schema.optionalKey(Schema.Number), - poolerPort: Schema.optionalKey(Schema.Number), - poolerApiPort: Schema.optionalKey(Schema.Number), + apiPort: Schema.optionalKey(Schema.Finite), + dbPort: Schema.optionalKey(Schema.Finite), + authPort: Schema.optionalKey(Schema.Finite), + postgrestPort: Schema.optionalKey(Schema.Finite), + postgrestAdminPort: Schema.optionalKey(Schema.Finite), + edgeRuntimePort: Schema.optionalKey(Schema.Finite), + edgeRuntimeInspectorPort: Schema.optionalKey(Schema.Finite), + realtimePort: Schema.optionalKey(Schema.Finite), + storagePort: Schema.optionalKey(Schema.Finite), + imgproxyPort: Schema.optionalKey(Schema.Finite), + mailpitPort: Schema.optionalKey(Schema.Finite), + mailpitSmtpPort: Schema.optionalKey(Schema.Finite), + mailpitPop3Port: Schema.optionalKey(Schema.Finite), + pgmetaPort: Schema.optionalKey(Schema.Finite), + studioPort: Schema.optionalKey(Schema.Finite), + analyticsPort: Schema.optionalKey(Schema.Finite), + poolerPort: Schema.optionalKey(Schema.Finite), + poolerApiPort: Schema.optionalKey(Schema.Finite), }); export const ResolvedPortsSchema = Schema.Struct({ ...PortSetSchema.fields, - apiPort: Schema.Number, - dbPort: Schema.Number, + apiPort: Schema.Finite, + dbPort: Schema.Finite, }); diff --git a/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts b/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts index 532b14ea4c..eb7b497214 100644 --- a/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts +++ b/packages/stack/src/RemoteStack.rpc.bun.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { Effect, Exit, Layer, Predicate, Scope } from "effect"; import { describe, expect, test } from "vitest"; import { ControlTransport } from "./managed/control.ts"; diff --git a/packages/stack/src/RemoteStack.rpc.integration.test.ts b/packages/stack/src/RemoteStack.rpc.integration.test.ts index f5cb42e4e8..2082d03317 100644 --- a/packages/stack/src/RemoteStack.rpc.integration.test.ts +++ b/packages/stack/src/RemoteStack.rpc.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/global-error-in-effect-failure, effecttsgo/node-builtin-import, effecttsgo/unnecessary-effect-gen -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { it } from "@effect/vitest"; import { Cause, diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index c0eec25100..b4015fbefe 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -1,4 +1,4 @@ -import { Effect, Exit, Layer, Match, Scope, Stream } from "effect"; +import { Cause, Effect, Exit, Layer, Match, Schema, Scope, Stream } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -90,9 +90,9 @@ const translateRpcClientFailure = ( procedure: string, ): StackRpcTransportError | StackRpcProtocolError => { const reason = error.reason; - if (reason instanceof RpcClientError.RpcClientDefect) + if (Schema.is(RpcClientError.RpcClientDefect)(reason)) return protocolError(endpoint, procedure, reason.message, reason.cause); - if (reason instanceof HttpClientError.HttpClientErrorSchema) + if (Schema.is(HttpClientError.HttpClientErrorSchema)(reason)) return reason.kind === "TransportError" ? transportError(endpoint, procedure, reason.cause ?? reason) : protocolError(endpoint, procedure, error.message, reason); @@ -101,14 +101,16 @@ const translateRpcClientFailure = ( const bodyForRequest = ( body: HttpBody.HttpBody, -): Effect.Effect<string | Uint8Array | undefined, unknown> => { +): Effect.Effect<string | Uint8Array | undefined, Cause.UnknownError> => { return Match.valueTags(body, { - Empty: () => Effect.succeed(undefined), - FormData: () => Effect.succeed(undefined), + Empty: () => Effect.as(Effect.void, undefined), + FormData: () => Effect.as(Effect.void, undefined), Uint8Array: (value) => Effect.succeed(value.body), Raw: (value) => Effect.succeed(typeof value.body === "string" ? value.body : undefined), Stream: (value) => - Stream.runCollect(value.stream).pipe( + Stream.runCollect( + value.stream.pipe(Stream.mapError((cause) => new Cause.UnknownError(cause))), + ).pipe( Effect.map((chunks) => { const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); const result = new Uint8Array(size); @@ -170,27 +172,23 @@ const makeRemoteRpcClient = ( .readOwner(endpoint, expectedOwner.ownershipId) .pipe(Effect.mapError((error) => controlErrorToRpc(endpoint, "owner", error))); if (options.cliVersion !== ownerStatus.daemonCliVersion) - return yield* Effect.fail( - new DaemonUpgradeRequired({ - stackId: options.stackId ?? expectedOwner.ownershipId, - oldCliVersion: ownerStatus.daemonCliVersion, - newCliVersion: options.cliVersion, - state: ownerStatus.state, - ready: ownerStatus.ready, - }), - ); + return yield* new DaemonUpgradeRequired({ + stackId: options.stackId ?? expectedOwner.ownershipId, + oldCliVersion: ownerStatus.daemonCliVersion, + newCliVersion: options.cliVersion, + state: ownerStatus.state, + ready: ownerStatus.ready, + }); if ( ownerStatus.ownershipId !== expectedOwner.ownershipId || ownerStatus.ownerSessionId !== expectedOwner.ownerSessionId || ownerStatus.controlProtocolVersion !== expectedOwner.controlProtocolVersion || ownerStatus.daemonCliVersion !== expectedOwner.daemonCliVersion ) - return yield* Effect.fail( - protocolError( - endpoint, - "owner", - "Remote supervisor owner descriptor changed before RPC construction", - ), + return yield* protocolError( + endpoint, + "owner", + "Remote supervisor owner descriptor changed before RPC construction", ); const rpcHttpClient = HttpClient.mapRequest( makeHttpClient(endpoint, transport, { @@ -214,7 +212,7 @@ type StackRpcFailure = StackRpcDomainError | RpcClientError.RpcClientError; const isRpcClientFailure = <E extends StackRpcFailure>( error: E, ): error is Extract<E, RpcClientError.RpcClientError> => - error instanceof RpcClientError.RpcClientError; + Schema.is(RpcClientError.RpcClientError)(error); const callRpc = <A, E extends StackRpcFailure, R>( endpoint: ControlEndpoint, diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 4e28514b93..4eced74d12 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/lazy-effect -- Callable service methods intentionally re-evaluate lifecycle effects against current stack state. import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Schema, Stream } from "effect"; @@ -46,7 +47,7 @@ export const StackInfoSchema = Schema.Struct({ const EdgeRuntimeConfigSchema = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - inspectorPort: Schema.optionalKey(Schema.Number), + inspectorPort: Schema.optionalKey(Schema.Finite), policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"])), env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index dd0a553046..06777d172c 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json, effecttsgo/run-effect-inside-effect -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, it } from "@effect/vitest"; import { NodeServices } from "@effect/platform-node"; import { buildGraph } from "@supabase/process-compose"; diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 1c4f288ea3..e2297e95d4 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { join } from "node:path"; import { buildGraph } from "@supabase/process-compose"; import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; @@ -124,12 +125,10 @@ const prepareNativePostgresAlias = ( const aliasPath = join(aliasRoot, "bundle"); if (/\s/.test(aliasPath) || (process.platform !== "darwin" && process.platform !== "linux")) { yield* fs.remove(aliasRoot, { recursive: true, force: true }).pipe(Effect.ignore); - return yield* Effect.fail( - new StackBuildError({ - detail: "Native PostgreSQL requires a Unix temporary path without whitespace", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "Native PostgreSQL requires a Unix temporary path without whitespace", + reason: "invalid_config", + }); } yield* fs.symlink(preparedPath, aliasPath).pipe( @@ -149,12 +148,10 @@ export const validateResolvedConfig = ( ): Effect.Effect<void, StackBuildError> => Effect.gen(function* () { if (config.instanceId !== undefined && !INSTANCE_ID_PATTERN.test(config.instanceId)) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `Invalid instanceId: must match ${INSTANCE_ID_PATTERN}`, + reason: "invalid_config", + }); } if (config.runtime.mode === "native") { @@ -162,40 +159,32 @@ export const validateResolvedConfig = ( (service) => resolvedConfigForService(config, service) !== false, ); if (enabledDockerOnly.length > 0) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Native mode supports only ${nativeServices.join(", ")}. Disable ${enabledDockerOnly.join(", ")} or select Docker mode with a usable Docker or Podman runtime.`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `Native mode supports only ${nativeServices.join(", ")}. Disable ${enabledDockerOnly.join(", ")} or select Docker mode with a usable Docker or Podman runtime.`, + reason: "invalid_config", + }); } } if (config.imgproxy !== false && config.storage === false) { - return yield* Effect.fail( - new StackBuildError({ - detail: "imgproxy requires storage to be enabled", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", + }); } if (config.vector !== false && config.analytics === false) { - return yield* Effect.fail( - new StackBuildError({ - detail: "vector requires analytics to be enabled", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "vector requires analytics to be enabled", + reason: "invalid_config", + }); } if (config.studio !== false && config.pgmeta === false) { - return yield* Effect.fail( - new StackBuildError({ - detail: "studio requires pgmeta to be enabled", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "studio requires pgmeta to be enabled", + reason: "invalid_config", + }); } }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 3a32510215..04b79ec0d6 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { describe, expect, it } from "@effect/vitest"; import { NodeFileSystem } from "@effect/platform-node"; import { Deferred, Effect, FileSystem, Layer, Predicate, Scope, Sink, Stream } from "effect"; diff --git a/packages/stack/src/StackConfigResolver.policy.unit.test.ts b/packages/stack/src/StackConfigResolver.policy.unit.test.ts index 7c23d5c71b..045196b4cb 100644 --- a/packages/stack/src/StackConfigResolver.policy.unit.test.ts +++ b/packages/stack/src/StackConfigResolver.policy.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, it } from "vitest"; import { NodeFileSystem } from "@effect/platform-node"; import { Cause, Effect, Exit, FileSystem } from "effect"; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 3970b0e99e..60721c8ab1 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { join } from "node:path"; import { Effect, Exit, FileSystem, Record, Schema } from "effect"; import type { PlatformError } from "effect/PlatformError"; @@ -244,7 +245,7 @@ function resolveFunctionsConfig( if (config.functions === undefined || config.functions === false) { return Effect.succeed(false); } - return Schema.decodeUnknownEffect(resolvedFunctionsBundleSchemaForProject(projectDir))( + return Schema.decodeEffect(resolvedFunctionsBundleSchemaForProject(projectDir))( config.functions, ).pipe( Effect.mapError( @@ -262,8 +263,8 @@ const resolveInstanceId = ( instanceId: string | undefined, ): Effect.Effect<string | undefined, StackBuildError> => instanceId === undefined - ? Effect.succeed(undefined) - : Schema.decodeUnknownEffect(InstanceIdSchema)(instanceId).pipe( + ? Effect.map(Effect.void, () => undefined) + : Schema.decodeEffect(InstanceIdSchema)(instanceId).pipe( Effect.mapError( (cause) => new StackBuildError({ @@ -476,22 +477,18 @@ const resolveServicePolicies = ( for (const service of SERVICE_NAMES) { const requested = requestedPolicies[service]; if (service === "postgres" && requested !== undefined && requested !== "eager") { - return yield* Effect.fail( - new StackBuildError({ - detail: "postgres supports only the eager service preparation policy", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "postgres supports only the eager service preparation policy", + reason: "invalid_config", + }); } const enabled = rawServiceEnabled(config, service); if (!enabled && requested !== undefined && requested !== "off") { - return yield* Effect.fail( - new StackBuildError({ - detail: `${service} cannot use the ${requested} service preparation policy because the service is not configured`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `${service} cannot use the ${requested} service preparation policy because the service is not configured`, + reason: "invalid_config", + }); } if (!enabled || requested === "off") { policies[service] = "off"; @@ -501,12 +498,10 @@ const resolveServicePolicies = ( const policy: Exclude<ServicePolicy, "off"> = requested === undefined ? DEFAULT_SERVICE_POLICIES[service] : requested; if (!serviceMetadata(service).preparation.supported.includes(policy)) { - return yield* Effect.fail( - new StackBuildError({ - detail: `${service} does not support the ${policy} service preparation policy`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `${service} does not support the ${policy} service preparation policy`, + reason: "invalid_config", + }); } policies[service] = policy; } @@ -526,12 +521,10 @@ const resolveServicePolicies = ( continue; } if (requestedPolicies[service] !== undefined) { - return yield* Effect.fail( - new StackBuildError({ - detail: `${dependency} uses the ${dependencyPolicy} preparation policy but requires ${service} to be at least ${dependencyPolicy}`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `${dependency} uses the ${dependencyPolicy} preparation policy but requires ${service} to be at least ${dependencyPolicy}`, + reason: "invalid_config", + }); } policies[service] = dependencyPolicy; promoted = true; @@ -562,22 +555,18 @@ export const portRequestsForConfig = ( options.runtime !== undefined && input.mode !== options.runtime.mode ) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Selected ${options.runtime.mode} runtime does not match requested ${input.mode} mode`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `Selected ${options.runtime.mode} runtime does not match requested ${input.mode} mode`, + reason: "invalid_config", + }); } const mode = options.runtime?.mode ?? input.mode ?? "native"; const config: StackConfig = { ...input, mode }; if (mode === "docker" && options.runtime?.containerRuntime == null) { - return yield* Effect.fail( - new StackBuildError({ - detail: "Docker mode requires a selected Docker or Podman runtime", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "Docker mode requires a selected Docker or Podman runtime", + reason: "invalid_config", + }); } // Deliberately first: unsupported policies and invalid explicit ports must @@ -640,12 +629,10 @@ export const portRequestsForConfig = ( explicit !== undefined && (!Number.isInteger(explicit) || explicit < 1 || explicit > 65_535) ) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Invalid port for ${field}: expected an integer between 1 and 65535`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `Invalid port for ${field}: expected an integer between 1 and 65535`, + reason: "invalid_config", + }); } } const unorderedRequests = activeFields.map((field) => { @@ -684,12 +671,10 @@ export function resolveConfig( const servicePolicies = yield* resolveServicePolicies(config); for (const field of portFieldsForConfigInput(config)) { if (opts.ports[field] === undefined) { - return yield* Effect.fail( - new StackBuildError({ - detail: `Missing resolved port for active field ${field}`, - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: `Missing resolved port for active field ${field}`, + reason: "invalid_config", + }); } } const projectDir = config.projectDir ?? process.cwd(); @@ -697,12 +682,10 @@ export function resolveConfig( const functions = yield* resolveFunctionsConfig(config, projectDir); const edgeRuntimeEnabled = servicePolicies["edge-runtime"] !== "off"; if (functions !== false && !edgeRuntimeEnabled) { - return yield* Effect.fail( - new StackBuildError({ - detail: "Edge Functions require Edge Runtime to be enabled", - reason: "invalid_config", - }), - ); + return yield* new StackBuildError({ + detail: "Edge Functions require Edge Runtime to be enabled", + reason: "invalid_config", + }); } roots = yield* resolveRoots(config, opts); const postgresInput = config.postgres ?? {}; diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 9e91795f56..b7e20a9f20 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -78,6 +78,7 @@ const RETRYABLE_PULL_PATTERNS = [ /i\/o timeout/i, ] as const; +// oxlint-disable-next-line effecttsgo/extends-native-error -- Internal retry state carries native daemon detail before mapping to DockerPullError. class PullAttemptError extends Error { constructor( readonly detail: string, @@ -225,6 +226,8 @@ export class StackPreparation extends Context.Service< ? (publishEvent?.(new ServiceDownloadFinished({ service })) ?? Effect.void) : Effect.void, ); + // This key is a deterministic in-memory cache identity. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- JSON provides stable structural keying. const key = JSON.stringify({ service, resolution, @@ -333,15 +336,14 @@ const pullImage = ( schedule: pullRetrySchedule, }), Effect.as(image), - Effect.catch((failure) => - Effect.fail( + Effect.mapError( + (failure) => new DockerPullError({ image, detail: `Failed to pull canonical Docker image. ${failure.detail}`, cause: new Error(failure.detail), daemonDown: failure.daemonDown, }), - ), ), ); }); diff --git a/packages/stack/src/StackRpc.ts b/packages/stack/src/StackRpc.ts index ea4f354d0e..7c238b318f 100644 --- a/packages/stack/src/StackRpc.ts +++ b/packages/stack/src/StackRpc.ts @@ -63,7 +63,7 @@ const ServiceNotFoundErrorSchema = Schema.TaggedStruct("ServiceNotFoundError", { const ServiceReadyErrorSchema = Schema.TaggedStruct("ServiceReadyError", { name: Schema.String, reason: Schema.String, - exitCode: Schema.optionalKey(Schema.Number), + exitCode: Schema.optionalKey(Schema.Finite), }).pipe( Schema.decodeTo( Schema.instanceOf(ServiceReadyError), @@ -103,7 +103,7 @@ const StackNotRunningErrorSchema = Schema.TaggedStruct("StackNotRunningError", { const StackReadinessErrorSchema = Schema.TaggedStruct("StackReadinessError", { target: Schema.String, - timeoutMs: Schema.Number, + timeoutMs: Schema.Finite, detail: Schema.String, }).pipe( Schema.decodeTo( @@ -147,15 +147,15 @@ const serviceStateErrors = Schema.Union([StackUnavailableErrorSchema, ServiceNot const StackServiceStateSchema = Schema.Struct({ name: Schema.String, status: StackServiceStatusSchema, - pid: Schema.NullOr(Schema.Number), - exitCode: Schema.NullOr(Schema.Number), - restartCount: Schema.Number, - startedAt: Schema.NullOr(Schema.Number), + pid: Schema.NullOr(Schema.Finite), + exitCode: Schema.NullOr(Schema.Finite), + restartCount: Schema.Finite, + startedAt: Schema.NullOr(Schema.Finite), error: Schema.NullOr(Schema.String), }); const StackLogEntrySchema = Schema.Struct({ - timestamp: Schema.Number, + timestamp: Schema.Finite, service: Schema.String, stream: Schema.Union([Schema.Literal("stdout"), Schema.Literal("stderr")]), line: Schema.String, @@ -166,7 +166,7 @@ const ReadyOptionsRpcSchema = ReadyOptionsSchema; const EdgeRuntimeReloadRpcSchema = Schema.Struct({ edgeRuntime: Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), - inspectorPort: Schema.optionalKey(Schema.Number), + inspectorPort: Schema.optionalKey(Schema.Finite), policy: Schema.optionalKey(Schema.Literals(["oneshot", "per_worker"])), env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }), @@ -246,7 +246,7 @@ export const StackRpc = RpcGroup.make( Rpc.make("GetLogHistory", { payload: { name: Schema.optionalKey(Schema.String), - limit: Schema.optionalKey(Schema.Number), + limit: Schema.optionalKey(Schema.Finite), services: Schema.optionalKey(Schema.Array(Schema.String)), }, success: Schema.Array(StackLogEntrySchema), diff --git a/packages/stack/src/StackRpcHandlers.integration.test.ts b/packages/stack/src/StackRpcHandlers.integration.test.ts index 7f91149c15..d5fd233d56 100644 --- a/packages/stack/src/StackRpcHandlers.integration.test.ts +++ b/packages/stack/src/StackRpcHandlers.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-fetch-in-effect, effecttsgo/prefer-schema-over-json -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { ServiceNotFoundError } from "@supabase/process-compose"; import { it } from "@effect/vitest"; import { Context, Effect, Layer, Stream } from "effect"; diff --git a/packages/stack/src/StackRpcHandlers.ts b/packages/stack/src/StackRpcHandlers.ts index 0247354330..4798648a49 100644 --- a/packages/stack/src/StackRpcHandlers.ts +++ b/packages/stack/src/StackRpcHandlers.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/multiple-catch-tag -- Separate transport/protocol defect mappings preserve their distinct typed boundaries. import { Context, Effect, Stream } from "effect"; import { StackBuildError, diff --git a/packages/stack/src/StackStateProjection.ts b/packages/stack/src/StackStateProjection.ts index 51b25512e8..401fa9a2fd 100644 --- a/packages/stack/src/StackStateProjection.ts +++ b/packages/stack/src/StackStateProjection.ts @@ -23,7 +23,15 @@ function projectPublicState( catalog: StackServiceProjectionCatalog, ): StackServiceState { if (raw.desired === "inactive" && (raw.status === "Pending" || raw.status === "Stopped")) { - return new StackServiceState({ ...fromRawServiceState(raw), status: "Dormant" }); + return new StackServiceState({ + name: raw.name, + status: "Dormant", + pid: raw.pid, + exitCode: raw.exitCode, + restartCount: raw.restartCount, + startedAt: raw.startedAt, + error: raw.error, + }); } const ownerHelpers = [...rawByName.values()].filter((candidate) => { diff --git a/packages/stack/src/SupervisorControlServer.integration.test.ts b/packages/stack/src/SupervisorControlServer.integration.test.ts index 2a5ee18286..f2a040bd7f 100644 --- a/packages/stack/src/SupervisorControlServer.integration.test.ts +++ b/packages/stack/src/SupervisorControlServer.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch-in-effect, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { Deferred, Effect, Fiber, Layer, ManagedRuntime, Predicate } from "effect"; import { HttpServer } from "effect/unstable/http"; diff --git a/packages/stack/src/SupervisorControlServer.ts b/packages/stack/src/SupervisorControlServer.ts index d3b517df78..00e6c08731 100644 --- a/packages/stack/src/SupervisorControlServer.ts +++ b/packages/stack/src/SupervisorControlServer.ts @@ -30,8 +30,12 @@ export const makeSupervisorControlApplication = ( ? StackRpcHandlers : StackRpcHandlers.pipe(Layer.provide(Layer.succeed(StackLaunchUpdater, launchUpdater))); const rpc = yield* RpcServer.toHttpEffect(StackRpc).pipe( - Effect.provide(handlers.pipe(Layer.provide(Layer.succeed(SupervisorLifecycle, lifecycle)))), - Effect.provide(RpcSerialization.layerNdjson), + Effect.provide( + Layer.mergeAll( + handlers.pipe(Layer.provide(Layer.succeed(SupervisorLifecycle, lifecycle))), + RpcSerialization.layerNdjson, + ), + ), ); const fencedRpc = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; @@ -82,6 +86,7 @@ export const makeSupervisorControlApplication = ( HttpRouter.route("POST", "/rpc", fencedRpc), ]; const application = yield* HttpRouter.toHttpEffect(HttpRouter.addAll(routes)); + // oxlint-disable-next-line effecttsgo/return-effect-in-gen -- The route application is intentionally returned as an Effect value for HttpServer wiring. return application.pipe(Effect.orDie); }); diff --git a/packages/stack/src/SupervisorLifecycle.integration.test.ts b/packages/stack/src/SupervisorLifecycle.integration.test.ts index c784b517e1..992c0b6fc2 100644 --- a/packages/stack/src/SupervisorLifecycle.integration.test.ts +++ b/packages/stack/src/SupervisorLifecycle.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { Cause, Deferred, Effect, Exit, Fiber, Scope, Stream } from "effect"; import { describe, expect, it } from "vitest"; import type { StackInfo } from "./Stack.ts"; diff --git a/packages/stack/src/SupervisorLifecycle.ts b/packages/stack/src/SupervisorLifecycle.ts index b6c97d046c..795b73a504 100644 --- a/packages/stack/src/SupervisorLifecycle.ts +++ b/packages/stack/src/SupervisorLifecycle.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Lifecycle teardown deliberately preserves the exact Cause from stop, dispose, and close operations. import { Context, Deferred, Effect, Exit, Ref, Scope } from "effect"; import { CONTROL_PROTOCOL, @@ -108,7 +109,7 @@ export class SupervisorLifecycle extends Context.Service< : Exit.isFailure(closeExit) ? closeExit.cause : undefined; - if (failure !== undefined) yield* Effect.failCause(failure); + if (failure !== undefined) return yield* Effect.failCause(failure); }), ); const completeShutdown = (exit: Exit.Exit<void, unknown>) => @@ -151,12 +152,10 @@ export class SupervisorLifecycle extends Context.Service< runtimeStack: Effect.gen(function* () { const state = yield* Ref.get(stateRef); if (state.phase === "running") return state.stack; - return yield* Effect.fail( - new StackUnavailableError({ - phase: state.phase === "closed" ? "stopping" : state.phase, - ...(state.phase === "failed" ? { detail: state.detail } : {}), - }), - ); + return yield* new StackUnavailableError({ + phase: state.phase === "closed" ? "stopping" : state.phase, + ...(state.phase === "failed" ? { detail: state.detail } : {}), + }); }), publishStack: (stack) => Ref.modify(stateRef, (state): [undefined, SupervisorState] => diff --git a/packages/stack/src/SupervisorProtocol.ts b/packages/stack/src/SupervisorProtocol.ts index 3688c24005..d1c1992140 100644 --- a/packages/stack/src/SupervisorProtocol.ts +++ b/packages/stack/src/SupervisorProtocol.ts @@ -35,7 +35,7 @@ export const SupervisorStartedEventSchema = Schema.Struct({ type: Schema.Literal("started"), endpoint: Schema.Struct({ hostname: Schema.String, - port: Schema.Number, + port: Schema.Finite, url: Schema.String, }), owner: SupervisorOwnerDescriptorSchema, diff --git a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts index eb2ef56392..75f014fc3c 100644 --- a/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts +++ b/packages/stack/src/SupervisorUpgradeRestart.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { NodeServices } from "@effect/platform-node"; import { it } from "@effect/vitest"; import { Cause, Effect, Exit, Fiber, Option } from "effect"; @@ -166,7 +167,7 @@ describe("incompatible supervisor upgrade restart", () => { manager: { ...context.manager, inspectStack: () => Effect.never }, controlTransport: context.transport, resolutionTimeout: "30 seconds", - reacquire: () => Effect.succeed(context.oldOwner), + reacquire: Effect.succeed(context.oldOwner), }).pipe( Effect.provide(NodeServices.layer), Effect.scoped, @@ -197,7 +198,7 @@ describe("incompatible supervisor upgrade restart", () => { ...context, configInput: context.configInput, controlTransport: context.transport, - reacquire: () => Effect.succeed(context.oldOwner), + reacquire: Effect.succeed(context.oldOwner), }).pipe( Effect.provide(NodeServices.layer), Effect.scoped, @@ -224,7 +225,7 @@ describe("incompatible supervisor upgrade restart", () => { input, configInput, controlTransport: context.transport, - reacquire: () => Effect.succeed(context.oldOwner), + reacquire: Effect.succeed(context.oldOwner), }).pipe( Effect.provide(NodeServices.layer), Effect.scoped, @@ -249,7 +250,7 @@ describe("incompatible supervisor upgrade restart", () => { input, configInput: context.configInput, controlTransport: context.transport, - reacquire: () => Effect.succeed(context.oldOwner), + reacquire: Effect.succeed(context.oldOwner), }).pipe( Effect.provide(NodeServices.layer), Effect.scoped, @@ -276,14 +277,13 @@ describe("incompatible supervisor upgrade restart", () => { ...context, configInput: context.configInput, controlTransport: context.transport, - reacquire: () => - Effect.fail( - new ControlBindError({ - endpoint: context.endpoint, - reason: "failed", - cause: new Error("restart endpoint unavailable"), - }), - ), + reacquire: Effect.fail( + new ControlBindError({ + endpoint: context.endpoint, + reason: "failed", + cause: new Error("restart endpoint unavailable"), + }), + ), }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { diff --git a/packages/stack/src/SupervisorUpgradeRestart.ts b/packages/stack/src/SupervisorUpgradeRestart.ts index b402e23693..de2911f835 100644 --- a/packages/stack/src/SupervisorUpgradeRestart.ts +++ b/packages/stack/src/SupervisorUpgradeRestart.ts @@ -43,7 +43,7 @@ export interface UpgradeRestartContext { readonly controlTransport: ControlTransportShape; readonly resolutionTimeout?: Duration.Input; /** Reclaims the deterministic endpoint after the captured owner disappears. */ - readonly reacquire: () => Effect.Effect< + readonly reacquire: Effect.Effect< ControlAcquisition, | InvalidControlOwnershipIdError | ControlBindError @@ -273,7 +273,7 @@ const preflight = ( .inspectStack(context.stackId) .pipe(Effect.mapError((cause) => preflightError(context, causeMessage(cause)))); if (existing === undefined) - return yield* Effect.fail(preflightError(context, "Managed stack document is missing")); + return yield* preflightError(context, "Managed stack document is missing"); const persistedRuntime = runtimeSelectionForLaunch(existing.launch); yield* validateStackRuntime(persistedRuntime).pipe( @@ -380,19 +380,18 @@ export const restartIncompatibleOwner = ( }), ), ), - Effect.catch(() => - Effect.fail( + Effect.mapError( + () => new StopTimeout({ endpoint: context.oldOwner.endpoint.url, ownerSessionId: context.oldOwner.observedStatus.ownerSessionId, lastState: context.oldOwner.observedStatus.state, }), - ), ), ), }), ); - const acquisition = yield* context.reacquire().pipe( + const acquisition = yield* context.reacquire.pipe( Effect.timeout(phaseTimeout), Effect.catchTag("TimeoutError", () => Effect.fail( diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index 9a5da207e0..2e1f557650 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Public Bun package APIs intentionally expose Promise facades over Effect programs. import { BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; @@ -51,6 +52,7 @@ export async function prefetch(options?: PrefetchOptions): Promise<PrefetchResul : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( prefetchEffect(resolvedOptions).pipe( + // oxlint-disable-next-line effecttsgo/multiple-effect-provide -- The preparation layer and Bun platform layer have ordered service ownership at this package edge. Effect.provide(preparationLayer), Effect.provide(BunServices.layer), ), diff --git a/packages/stack/src/cleanup.ts b/packages/stack/src/cleanup.ts index 0c51d512fd..2f75b53dfe 100644 --- a/packages/stack/src/cleanup.ts +++ b/packages/stack/src/cleanup.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- This module owns a native subprocess boundary that cannot be expressed through an Effect service. import { execFile } from "node:child_process"; import { Data, Duration, Effect, FileSystem, Schedule } from "effect"; import type { ContainerRuntime } from "./ContainerRuntime.ts"; @@ -59,7 +60,7 @@ const cleanupAutoManagedPathsWithRetry = ( fs.exists(path).pipe(Effect.catchTag("PlatformError", () => Effect.succeed(true))), { concurrency: 4 }, ); - if (remaining.some(Boolean)) yield* Effect.fail(new CleanupPending()); + if (remaining.some(Boolean)) return yield* new CleanupPending(); }).pipe(Effect.uninterruptible); const retries = Effect.sleep(Duration.millis(250)).pipe( Effect.andThen( @@ -93,7 +94,7 @@ export const cleanupLocalStackResources = (opts: { // exited or the scope is partially closed. Make the stop path // uninterruptible so SIGTERM-driven scope closure does not abandon it // mid-shutdown and leak child processes. - yield* Effect.uninterruptible(opts.stop()).pipe(Effect.catch(() => Effect.void)); + yield* Effect.uninterruptible(opts.stop()).pipe(Effect.ignore); // Safety net: force-remove any Docker containers that survived // signal-based shutdown. On macOS, killing the `docker run` client diff --git a/packages/stack/src/compiled-supervisor.integration.test.ts b/packages/stack/src/compiled-supervisor.integration.test.ts index cc37ca7deb..629fb55d61 100644 --- a/packages/stack/src/compiled-supervisor.integration.test.ts +++ b/packages/stack/src/compiled-supervisor.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/catch-to-or-else-succeed, effecttsgo/crypto-random-uuid, effecttsgo/effect-succeed-with-void, effecttsgo/extends-native-error, effecttsgo/global-error-in-effect-failure, effecttsgo/global-fetch, effecttsgo/global-fetch-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json, effecttsgo/process-env, effecttsgo/unknown-in-effect-catch -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { fork, type ChildProcess } from "node:child_process"; @@ -228,7 +230,7 @@ const waitForEndpointUnavailable = (endpoint: ControlEndpoint): Promise<void> => catch: (cause) => cause, }).pipe( Effect.catch((cause) => - cause instanceof EndpointStillAliveError ? Effect.fail(cause) : Effect.succeed(undefined), + cause instanceof EndpointStillAliveError ? Effect.fail(cause) : Effect.void, ), Effect.retry(Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" }))), Effect.asVoid, diff --git a/packages/stack/src/createStack.integration.test.ts b/packages/stack/src/createStack.integration.test.ts index 5cff352cfe..7d744400c5 100644 --- a/packages/stack/src/createStack.integration.test.ts +++ b/packages/stack/src/createStack.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { createServer } from "node:net"; import { existsSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index eaaa07d4f7..1b9fc4a89e 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/lazy-effect -- The public foreground handle exposes callable operations so each invocation observes current lifecycle state. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- The package edge accepts arbitrary platform failures and maps them to StackError. import type { LogEntry } from "@supabase/process-compose"; import { Cause, @@ -211,7 +213,7 @@ const createStackAttempt = ( apiProxy.awaitTerminalFailure.pipe( Effect.andThen(Effect.sleep("25 millis")), Effect.andThen(dispose), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ), ); diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 6c162a1c2c..b4554fcd1c 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-error-in-effect-failure, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, it } from "vitest"; import { NodeFileSystem } from "@effect/platform-node"; import { Cause, Effect, Exit, Result } from "effect"; diff --git a/packages/stack/src/daemon-bun.ts b/packages/stack/src/daemon-bun.ts index 98f067f608..ee488b4e1d 100644 --- a/packages/stack/src/daemon-bun.ts +++ b/packages/stack/src/daemon-bun.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/any-unknown-in-error-context -- The detached Bun entrypoint forwards the supervisor's process-boundary Cause. import { BunFileSystem, BunServices } from "@effect/platform-bun"; import { Effect, Layer } from "effect"; import { runSupervisor } from "./supervisor.ts"; @@ -14,10 +15,14 @@ const managerLayer = (stateRoot: string) => export const runBunDaemon = (): void => { void Effect.runPromise( runSupervisor({ platformFactory, managerLayer }).pipe( - Effect.provide(BunServices.layer), - Effect.provide(BunFileSystem.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), + Effect.provide( + Layer.mergeAll( + BunServices.layer, + BunFileSystem.layer, + gitConfigStoreLayer, + controlTransportLayer, + ), + ), ), ); }; diff --git a/packages/stack/src/daemon-node.ts b/packages/stack/src/daemon-node.ts index fe9bf2e0cf..a1c2bbc2b7 100644 --- a/packages/stack/src/daemon-node.ts +++ b/packages/stack/src/daemon-node.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/any-unknown-in-error-context -- The detached Node entrypoint forwards the supervisor's process-boundary Cause. import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; import { runSupervisor } from "./supervisor.ts"; @@ -21,11 +22,15 @@ const managerLayer = (stateRoot: string) => export const runNodeSupervisor = (): void => { void Effect.runPromise( runSupervisor({ platformFactory, managerLayer }).pipe( - Effect.provide(NodeServices.layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - Effect.provide(gitConfigStoreLayer), - Effect.provide(controlTransportLayer), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeFileSystem.layer, + NodePath.layer, + gitConfigStoreLayer, + controlTransportLayer, + ), + ), ), ); }; diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts index 5fa3e7af7a..50d343163e 100644 --- a/packages/stack/src/effect-bun.ts +++ b/packages/stack/src/effect-bun.ts @@ -1,8 +1,9 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. // @supabase/stack/effect — Bun-bound Effect interfaces and consumer layers. export * from "./effect.ts"; -import { Effect, type Layer } from "effect"; +import { Effect, Layer } from "effect"; import { join } from "node:path"; import type { PortLease } from "./PortAllocator.ts"; import type { Stack } from "./Stack.ts"; @@ -74,6 +75,5 @@ export const updateManagedLaunch = (opts: { readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( - Effect.provide(managedLayer(opts.cacheRoot)), - Effect.provide(httpTransportClientLayer), + Effect.provide(Layer.mergeAll(managedLayer(opts.cacheRoot), httpTransportClientLayer)), ); diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts index 21415a1767..50467c4ebe 100644 --- a/packages/stack/src/effect-node.ts +++ b/packages/stack/src/effect-node.ts @@ -1,8 +1,9 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. // @supabase/stack/effect — Node-bound Effect interfaces and consumer layers. export * from "./effect.ts"; -import { Effect, type Layer } from "effect"; +import { Effect, Layer } from "effect"; import { join } from "node:path"; import type { PortLease } from "./PortAllocator.ts"; import type { Stack } from "./Stack.ts"; @@ -74,6 +75,5 @@ export const updateManagedLaunch = (opts: { readonly launch: import("./managed/document.ts").ManagedStackLaunchUpdate; }) => updateManagedLaunchCore(opts).pipe( - Effect.provide(managedLayer(opts.cacheRoot)), - Effect.provide(httpTransportClientLayer), + Effect.provide(Layer.mergeAll(managedLayer(opts.cacheRoot), httpTransportClientLayer)), ); diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 4d3d5b9c4a..795e0ff62a 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -150,6 +150,7 @@ export class PortConflictError extends Data.TaggedError("PortConflictError")<{ readonly service: string; }> {} +// oxlint-disable-next-line effecttsgo/extends-native-error -- Public Promise adapter exposes a conventional Error with stable code/cause fields. export class StackError extends Error { readonly code: string; constructor(opts: { code: string; message: string; cause?: unknown }) { diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index 478790fe6e..c20bb0c39f 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Synchronous schema path validation runs before an Effect program and requires native path resolution. import { existsSync, realpathSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Effect, FileSystem, Path, Schema } from "effect"; @@ -204,10 +205,14 @@ const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( const path = yield* Path.Path; const filePath = functionsRuntimeConfigPath(runtimeRoot); const directory = path.dirname(filePath); + // Temporary filenames only need host-level uniqueness at this filesystem boundary. + // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect -- Native temp-file coordination boundary. const temporaryPath = `${filePath}.tmp-${crypto.randomUUID()}`; yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); yield* Effect.gen(function* () { + // The persisted runtime config is a stable JSON file consumed by the edge runtime. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- JSON is the on-disk interchange format. yield* fs.writeFileString(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { flag: "wx", mode: 0o600, diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 594cd70e58..a74e665252 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, it } from "@effect/vitest"; import { NodeServices } from "@effect/platform-node"; import { mkdtempSync, symlinkSync } from "node:fs"; diff --git a/packages/stack/src/layers.ts b/packages/stack/src/layers.ts index deee8c28bb..049ae58306 100644 --- a/packages/stack/src/layers.ts +++ b/packages/stack/src/layers.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { join } from "node:path"; import { Data, Effect, Layer } from "effect"; import { FileSystem, Path } from "effect"; diff --git a/packages/stack/src/managed-control.integration.test.ts b/packages/stack/src/managed-control.integration.test.ts index d3fc00514c..525aca5a80 100644 --- a/packages/stack/src/managed-control.integration.test.ts +++ b/packages/stack/src/managed-control.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/crypto-random-uuid-in-effect, effecttsgo/global-fetch-in-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json, effecttsgo/unnecessary-effect-gen -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { it } from "@effect/vitest"; import { Cause, Deferred, Effect, Exit, Fiber, Layer, Predicate, Result, Stream } from "effect"; import * as TestClock from "effect/testing/TestClock"; diff --git a/packages/stack/src/managed-environment.integration.test.ts b/packages/stack/src/managed-environment.integration.test.ts index 6cd31fd6c2..a459e1dda8 100644 --- a/packages/stack/src/managed-environment.integration.test.ts +++ b/packages/stack/src/managed-environment.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { NodeFileSystem } from "@effect/platform-node"; import { it } from "@effect/vitest"; import { Effect, Exit, Layer } from "effect"; diff --git a/packages/stack/src/managed-manager-lifecycle.integration.test.ts b/packages/stack/src/managed-manager-lifecycle.integration.test.ts index f07e509836..b1ddf914b4 100644 --- a/packages/stack/src/managed-manager-lifecycle.integration.test.ts +++ b/packages/stack/src/managed-manager-lifecycle.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/crypto-random-uuid-in-effect, effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer } from "effect"; @@ -473,9 +475,11 @@ describe("managed stack lifecycle journeys", () => { const probe = yield* manager.probeControl(stackId); if (probe === undefined) throw new Error("expected deleting owner"); const response = yield* Effect.tryPromise(() => + // oxlint-disable-next-line effecttsgo/global-fetch-in-effect -- Integration test exercises the native control HTTP endpoint. fetch(`${probe.endpoint.url}/stop`, { method: "POST", headers: { "content-type": "application/json" }, + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Integration test exercises the raw control-protocol JSON boundary. body: JSON.stringify({ ownershipId: stackId, ownerSessionId: probe.status.ownerSessionId, diff --git a/packages/stack/src/managed-manager-ports.integration.test.ts b/packages/stack/src/managed-manager-ports.integration.test.ts index f7e27c3072..32daedc283 100644 --- a/packages/stack/src/managed-manager-ports.integration.test.ts +++ b/packages/stack/src/managed-manager-ports.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Effect, Exit } from "effect"; diff --git a/packages/stack/src/managed-manager-projects.integration.test.ts b/packages/stack/src/managed-manager-projects.integration.test.ts index 57ef7492d9..e0ed426a31 100644 --- a/packages/stack/src/managed-manager-projects.integration.test.ts +++ b/packages/stack/src/managed-manager-projects.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"; diff --git a/packages/stack/src/managed-manager-recovery.integration.test.ts b/packages/stack/src/managed-manager-recovery.integration.test.ts index 59b3d217bb..2a6b855ae6 100644 --- a/packages/stack/src/managed-manager-recovery.integration.test.ts +++ b/packages/stack/src/managed-manager-recovery.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/global-error-in-effect-failure, effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Cause, Deferred, Effect, Exit, Fiber, FileSystem, Layer, PlatformError } from "effect"; diff --git a/packages/stack/src/managed-manager-worktrees.integration.test.ts b/packages/stack/src/managed-manager-worktrees.integration.test.ts index 9c2120eee8..56f7de8530 100644 --- a/packages/stack/src/managed-manager-worktrees.integration.test.ts +++ b/packages/stack/src/managed-manager-worktrees.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/multiple-effect-provide, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { it } from "@effect/vitest"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { Effect } from "effect"; diff --git a/packages/stack/src/managed-node.ts b/packages/stack/src/managed-node.ts index b349c930d9..18b67168d0 100644 --- a/packages/stack/src/managed-node.ts +++ b/packages/stack/src/managed-node.ts @@ -30,6 +30,7 @@ export const managedDaemonLayer = ( input: ManagedDaemonStartInput, ): ReturnType<typeof managedDaemonLayerForPlatform> => managedDaemonLayerForPlatform(input, managedDaemonEntryPoint).pipe( + // oxlint-disable-next-line effecttsgo/multiple-effect-provide -- Node daemon entrypoint layers are ordered platform bindings. Effect.provide(NodeFileSystem.layer), Effect.provide(NodePath.layer), ); diff --git a/packages/stack/src/managed-paths.unit.test.ts b/packages/stack/src/managed-paths.unit.test.ts index d9c2778262..d16b843016 100644 --- a/packages/stack/src/managed-paths.unit.test.ts +++ b/packages/stack/src/managed-paths.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { Effect } from "effect"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; diff --git a/packages/stack/src/managed-store.integration.test.ts b/packages/stack/src/managed-store.integration.test.ts index 2434feb010..6e37ae7ce2 100644 --- a/packages/stack/src/managed-store.integration.test.ts +++ b/packages/stack/src/managed-store.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/prefer-schema-over-json -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, PlatformError, Predicate } from "effect"; diff --git a/packages/stack/src/managed/atomic-claim.integration.test.ts b/packages/stack/src/managed/atomic-claim.integration.test.ts index 72d7d8cd36..ba33651a00 100644 --- a/packages/stack/src/managed/atomic-claim.integration.test.ts +++ b/packages/stack/src/managed/atomic-claim.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { NodeFileSystem } from "@effect/platform-node"; import { it } from "@effect/vitest"; import { Deferred, Effect, Fiber, FileSystem, Layer, PlatformError } from "effect"; diff --git a/packages/stack/src/managed/atomic-claim.ts b/packages/stack/src/managed/atomic-claim.ts index d0c84222ba..93e9eab558 100644 --- a/packages/stack/src/managed/atomic-claim.ts +++ b/packages/stack/src/managed/atomic-claim.ts @@ -111,7 +111,7 @@ const publish = ( if (isHardLinkUnsupported(linkError.value)) { return yield* unsupportedHardLink(targetPath, linkError.value); } - return yield* Effect.fail(linkError.value); + return yield* linkError.value; }); /** diff --git a/packages/stack/src/managed/control.ts b/packages/stack/src/managed/control.ts index 876e075c7c..7938307d77 100644 --- a/packages/stack/src/managed/control.ts +++ b/packages/stack/src/managed/control.ts @@ -267,14 +267,14 @@ const waitForControlSessionEnd = ( ), ), ), - // A valid owner for another identity can claim this candidate after the - // captured session releases it. That proves the captured session ended. - Effect.catchTag("ControlAddressConflictError", () => Effect.void), - // Once the captured listener has closed, an unrelated listener may bind - // the same endpoint before this observer runs. A malformed response or - // a different control protocol therefore proves that the old session is - // gone just like a foreign owner response does. Effect.catchTags({ + // A valid owner for another identity can claim this candidate after the + // captured session releases it. That proves the captured session ended. + ControlAddressConflictError: () => Effect.void, + // Once the captured listener has closed, an unrelated listener may bind + // the same endpoint before this observer runs. A malformed response or + // a different control protocol therefore proves that the old session is + // gone just like a foreign owner response does. ControlProtocolError: () => Effect.void, ControlProtocolMismatchError: () => Effect.void, }), @@ -374,6 +374,8 @@ const defaultStatus = ( controlProtocol: CONTROL_PROTOCOL, controlProtocolVersion: CONTROL_PROTOCOL_VERSION, ownershipId, + // Session identifiers fence native control ownership across processes. + // oxlint-disable-next-line effecttsgo/crypto-random-uuid -- Native process ownership boundary. ownerSessionId: crypto.randomUUID(), state: "starting", ready: false, @@ -469,7 +471,7 @@ export const probeControl = ( const transport = yield* ControlTransport; for (const endpoint of candidates) { const status = yield* readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( - Effect.catch(() => Effect.succeed(undefined)), + Effect.catch(() => Effect.void), ); if (status !== undefined) return { status, endpoint }; } @@ -548,11 +550,12 @@ const scanForOwner = ( for (const endpoint of candidates) { const status = yield* readControlOwnerStatus(endpoint, ownershipId, transport.read).pipe( Effect.map((status) => status), - Effect.catchTag("ControlTransportError", (cause) => - cause.reason === "unreachable" ? Effect.succeed(undefined) : Effect.fail(cause), - ), - Effect.catchTag("ControlProtocolError", () => Effect.succeed(undefined)), - Effect.catchTag("ControlAddressConflictError", () => Effect.succeed(undefined)), + Effect.catchTags({ + ControlTransportError: (cause) => + cause.reason === "unreachable" ? Effect.void : Effect.fail(cause), + ControlProtocolError: () => Effect.void, + ControlAddressConflictError: () => Effect.void, + }), ); if (status !== undefined) return { endpoint, status }; } @@ -620,7 +623,7 @@ const acquireAtCandidates = ( return owned; } const error = bound.failure; - if (error.reason !== "in-use") return yield* Effect.fail(error); + if (error.reason !== "in-use") return yield* error; // The address was taken between the scan and the bind: attach if the // occupant is our owner, retry the walk if it is not serving yet, and // move to the next candidate if it belongs to someone else. @@ -630,36 +633,35 @@ const acquireAtCandidates = ( transport, ).pipe( Effect.map((acquisition): ControlAcquisition | undefined => acquisition), - Effect.catchTag("ControlAddressConflictError", (cause) => - Effect.sync(() => { - conflict = cause; - return undefined; - }), - ), - Effect.catchTag("ControlProtocolError", (cause) => - Effect.sync(() => { - conflict = new ControlAddressConflictError({ endpoint, cause }); - return undefined; - }), - ), - Effect.catchTag("ControlTransportError", (cause) => - cause.reason === "unreachable" - ? Effect.sync(() => { - pending = unavailable(endpoint, cause); - return undefined; - }) - : Effect.fail(cause), - ), + Effect.catchTags({ + ControlAddressConflictError: (cause) => + Effect.sync(() => { + conflict = cause; + return undefined; + }), + ControlProtocolError: (cause) => + Effect.sync(() => { + conflict = new ControlAddressConflictError({ endpoint, cause }); + return undefined; + }), + ControlTransportError: (cause) => + cause.reason === "unreachable" + ? Effect.sync(() => { + pending = unavailable(endpoint, cause); + return undefined; + }) + : Effect.fail(cause), + }), ); if (attached !== undefined) return attached; } - if (pending !== undefined) return yield* Effect.fail(pending); - return yield* Effect.fail( + if (pending !== undefined) return yield* pending; + return yield* ( conflict ?? new ControlAddressConflictError({ endpoint: candidates[0]!, cause: new Error("Every control endpoint candidate is occupied"), - }), + }) ); }); diff --git a/packages/stack/src/managed/document.ts b/packages/stack/src/managed/document.ts index 520682a313..715f8e8985 100644 --- a/packages/stack/src/managed/document.ts +++ b/packages/stack/src/managed/document.ts @@ -83,7 +83,7 @@ const managedPortAssignmentSchema = Schema.Struct({ "analytics.port", "db.pooler.port", ]), - port: Schema.Number, + port: Schema.Finite, intent: Schema.Literals(["automatic", "exact"]), }); @@ -109,7 +109,7 @@ const managedStackDocumentSchema = Schema.Struct({ stopIntent: Schema.optionalKey(Schema.Literal("explicit")), runtime: Schema.optionalKey( Schema.Struct({ - pid: Schema.Number, + pid: Schema.Finite, controlEndpoint: Schema.String, protocolVersion: Schema.Literal(1), }), @@ -139,7 +139,7 @@ export const decodeManagedStackDocument = ( path: string, content: string, ): Effect.Effect<ManagedStackDocument, InvalidManagedStackDocumentError> => - Schema.decodeUnknownEffect(ManagedStackDocumentSchema)(content).pipe( + Schema.decodeEffect(ManagedStackDocumentSchema)(content).pipe( Effect.mapError(() => new InvalidManagedStackDocumentError({ path })), Effect.flatMap((document) => hasCorePortAssignments(document) @@ -154,10 +154,12 @@ export const encodeManagedStackDocument = ( ): Effect.Effect<string, InvalidManagedStackDocumentError> => Effect.gen(function* () { if (!hasCorePortAssignments(document)) { - return yield* Effect.fail(new InvalidManagedStackDocumentError({ path })); + return yield* new InvalidManagedStackDocumentError({ path }); } const encoded = yield* Schema.encodeEffect(managedStackDocumentSchema)(document).pipe( Effect.mapError(() => new InvalidManagedStackDocumentError({ path })), ); + // Managed documents use JSON as their durable file format. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- This is the persistence boundary. return JSON.stringify(encoded, null, 2) + "\n"; }); diff --git a/packages/stack/src/managed/environment.ts b/packages/stack/src/managed/environment.ts index 0b978080ab..4e8b92b3db 100644 --- a/packages/stack/src/managed/environment.ts +++ b/packages/stack/src/managed/environment.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { createHash } from "node:crypto"; import { isAbsolute, relative, sep } from "node:path"; import { Effect, FileSystem } from "effect"; @@ -290,11 +291,9 @@ export const validateEnvironmentRepair = ( Effect.gen(function* () { const current = yield* discoverInternal(request.path); if (request.reason === "duplicate") { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: "Duplicate checkout evidence requires an explicit ownership decision", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Duplicate checkout evidence requires an explicit ownership decision", + }); } const currentUpdates = current.state === "needsRepair" ? current.repair.updates : []; const requestedUpdates = request.updates; @@ -318,9 +317,9 @@ export const validateEnvironmentRepair = ( current.identity.contextId !== request.identity.contextId || !updatesMatch ) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ message: "Workspace identity changed before repair" }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Workspace identity changed before repair", + }); } return current.repair; }); diff --git a/packages/stack/src/managed/failure.ts b/packages/stack/src/managed/failure.ts index c7193c5186..faf7bdf50a 100644 --- a/packages/stack/src/managed/failure.ts +++ b/packages/stack/src/managed/failure.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/any-unknown-in-error-context -- This recovery helper intentionally accepts arbitrary foreign failures before narrowing the protocol error. import { Effect } from "effect"; /** A stable human-readable rendering for failures crossing process boundaries. */ diff --git a/packages/stack/src/managed/git-identity.ts b/packages/stack/src/managed/git-identity.ts index 1400f86ef4..813585e478 100644 --- a/packages/stack/src/managed/git-identity.ts +++ b/packages/stack/src/managed/git-identity.ts @@ -17,7 +17,7 @@ const gitCheckoutIdentitySchema = Schema.fromJsonString( export const decodeGitCheckoutIdentity = ( content: string, ): Effect.Effect<GitCheckoutIdentity, InvalidManagedIdentityError> => - Schema.decodeUnknownEffect(gitCheckoutIdentitySchema)(content).pipe( + Schema.decodeEffect(gitCheckoutIdentitySchema)(content).pipe( Effect.mapError( (error) => new InvalidManagedIdentityError({ diff --git a/packages/stack/src/managed/git.integration.test.ts b/packages/stack/src/managed/git.integration.test.ts index 0fbd6728e4..1de9fde4a4 100644 --- a/packages/stack/src/managed/git.integration.test.ts +++ b/packages/stack/src/managed/git.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import, effecttsgo/unnecessary-effect-gen -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { BunFileSystem } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect"; diff --git a/packages/stack/src/managed/git.ts b/packages/stack/src/managed/git.ts index 47df4409dd..260b8e2d35 100644 --- a/packages/stack/src/managed/git.ts +++ b/packages/stack/src/managed/git.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- This module owns a native subprocess boundary that cannot be expressed through an Effect service. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Git inspection narrows arbitrary filesystem causes at the protocol boundary. import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import { dirname, isAbsolute, join, resolve } from "node:path"; @@ -133,7 +135,9 @@ const readOptionalFile = ( path: string, ): Effect.Effect<string | undefined, PlatformError.PlatformError> => Effect.catch(fs.readFileString(path), (error) => - Predicate.isTagged(error.reason, "NotFound") ? Effect.succeed(undefined) : Effect.fail(error), + Predicate.isTagged(error.reason, "NotFound") + ? Effect.map(Effect.void, () => undefined) + : Effect.fail(error), ); const realPathOrMalformed = ( @@ -549,6 +553,7 @@ export interface GitConfigStoreShape { * claiming at once safe. Reads go through it too, because a value may be quoted * or continued across lines and only git decides what it means. */ +// oxlint-disable-next-line effecttsgo/leaking-requirements -- Platform-specific callers intentionally provide FileSystem to each git operation. export class GitConfigStore extends Context.Service<GitConfigStore, GitConfigStoreShape>()( "stack/managed/GitConfigStore", ) {} @@ -795,16 +800,13 @@ const ensureConfigId = ( const settled = settledValue(values); if (settled === undefined) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ message: `${key} was claimed but is not set` }), - ); + return yield* new InvalidManagedIdentityError({ + message: `${key} was claimed but is not set`, + }); } const id = yield* requireUuid(settled, label); if (values.length > 1) { - yield* Effect.catchDefect( - Effect.catch(store.replace(file, key, id), () => Effect.void), - () => Effect.void, - ); + yield* Effect.catchDefect(Effect.ignore(store.replace(file, key, id)), () => Effect.void); } return id; }); @@ -827,7 +829,7 @@ const readCheckoutIdentity = ( const content = yield* fs.readFileString(gitCheckoutIdentityPath(gitDirectory)).pipe( Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed<string | undefined>(undefined) + ? Effect.map(Effect.void, () => undefined) : Effect.fail( new UnsupportedGitWorkspaceError({ path: gitCheckoutIdentityPath(gitDirectory), @@ -859,27 +861,29 @@ const ensureCheckoutIdentity = ( }; const outcome = yield* claimFileAtomically( markerPath, + // Git checkout identity is a durable marker file. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- This is the persistence boundary. `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 }, ).pipe( - Effect.catchTag("AtomicClaimUnsupportedError", (error) => - Effect.fail( - new UnsupportedGitWorkspaceError({ - path: markerPath, - reason: error.message, - workspaceCause: "metadata-inaccessible", - }), - ), - ), - Effect.catchTag("PlatformError", (error) => - Effect.fail( - new UnsupportedGitWorkspaceError({ - path: gitCheckoutIdentityPath(gitDirectory), - reason: `Git checkout identity is inaccessible (${error.message})`, - workspaceCause: "metadata-inaccessible", - }), - ), - ), + Effect.catchTags({ + AtomicClaimUnsupportedError: (error) => + Effect.fail( + new UnsupportedGitWorkspaceError({ + path: markerPath, + reason: error.message, + workspaceCause: "metadata-inaccessible", + }), + ), + PlatformError: (error) => + Effect.fail( + new UnsupportedGitWorkspaceError({ + path: gitCheckoutIdentityPath(gitDirectory), + reason: `Git checkout identity is inaccessible (${error.message})`, + workspaceCause: "metadata-inaccessible", + }), + ), + }), ); if (outcome === "claimed") { return { checkoutId: identity.checkoutId, created: true }; @@ -887,11 +891,9 @@ const ensureCheckoutIdentity = ( const winner = yield* readCheckoutIdentity(gitDirectory); if (winner === undefined) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: "Checkout identity publication raced without a winning marker", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Checkout identity publication raced without a winning marker", + }); } return { checkoutId: winner.checkoutId, created: false }; }); @@ -980,7 +982,7 @@ export const readGitCheckoutIdentityWithFileSystem = ( .pipe( Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed(undefined) + ? Effect.void : Effect.fail( new UnsupportedGitWorkspaceError({ path: gitCheckoutIdentityPath(inspection.gitDirectory), diff --git a/packages/stack/src/managed/identity.ts b/packages/stack/src/managed/identity.ts index 1a5b62cb60..4760c09270 100644 --- a/packages/stack/src/managed/identity.ts +++ b/packages/stack/src/managed/identity.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Identity recovery narrows arbitrary filesystem causes at this helper boundary. import { randomUUID } from "node:crypto"; import { dirname } from "node:path"; import { Effect, FileSystem, PlatformError, Predicate, Schema } from "effect"; @@ -30,7 +32,7 @@ const ordinaryWorkspaceIdentitySchema = Schema.fromJsonString( const decodeIdentity = ( content: string, ): Effect.Effect<OrdinaryWorkspaceIdentity, InvalidManagedIdentityError> => - Schema.decodeUnknownEffect(ordinaryWorkspaceIdentitySchema)(content).pipe( + Schema.decodeEffect(ordinaryWorkspaceIdentitySchema)(content).pipe( Effect.mapError( (error) => new InvalidManagedIdentityError({ @@ -59,20 +61,20 @@ const claimIdentityFile = ( mode?: number, ): Effect.Effect<FileClaimOutcome, InvalidManagedIdentityError, FileSystem.FileSystem> => claimFileAtomically(path, content, { mode }).pipe( - Effect.catchTag("AtomicClaimUnsupportedError", (error) => - Effect.fail( - new InvalidManagedIdentityError({ - message: `${label} could not be published at ${path}: ${error.message}. The filesystem must support hard links for managed identity publication.`, - }), - ), - ), - Effect.catchTag("PlatformError", (error) => - Effect.fail( - new InvalidManagedIdentityError({ - message: `${label} could not be published at ${path}: ${error.message}`, - }), - ), - ), + Effect.catchTags({ + AtomicClaimUnsupportedError: (error) => + Effect.fail( + new InvalidManagedIdentityError({ + message: `${label} could not be published at ${path}: ${error.message}. The filesystem must support hard links for managed identity publication.`, + }), + ), + PlatformError: (error) => + Effect.fail( + new InvalidManagedIdentityError({ + message: `${label} could not be published at ${path}: ${error.message}`, + }), + ), + }), ); /** Effect FileSystem variant used by managed discovery. */ @@ -84,9 +86,9 @@ export const canonicalizeManagedWorkspacePathWithFileSystem = ( const fs = yield* FileSystem.FileSystem; const info = yield* fs.stat(workspacePath); if (info.type !== "Directory") { - return yield* Effect.fail( - new InvalidManagedIdentityError({ message: `${workspacePath} is not a directory` }), - ); + return yield* new InvalidManagedIdentityError({ + message: `${workspacePath} is not a directory`, + }); } return yield* fs.realPath(workspacePath); }).pipe( @@ -114,7 +116,7 @@ const readIdentity = ( Effect.flatMap((content) => decodeIdentity(content)), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed<OrdinaryWorkspaceIdentity | undefined>(undefined) + ? Effect.map(Effect.void, () => undefined) : Effect.fail(inaccessibleIdentity("Ordinary workspace identity", error)), ), ); @@ -154,6 +156,8 @@ export const ensureOrdinaryWorkspaceIdentity = ( yield* fs.makeDirectory(dirname(markerPath), { recursive: true }); const outcome = yield* claimIdentityFile( markerPath, + // Identity markers are durable JSON files shared with older managed workspaces. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- This is the persistence boundary. `${JSON.stringify(identity, null, 2)}\n`, "Ordinary workspace identity", 0o600, @@ -162,11 +166,9 @@ export const ensureOrdinaryWorkspaceIdentity = ( const winner = yield* readIdentity(workspacePath); if (winner === undefined) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: "Identity publication raced without a winning marker", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Identity publication raced without a winning marker", + }); } return { identity: winner, created: false, markerPath }; }), @@ -184,7 +186,7 @@ const detachedContextIdentitySchema = Schema.fromJsonString( const decodeDetachedContextId = ( content: string, ): Effect.Effect<string, InvalidManagedIdentityError> => - Schema.decodeUnknownEffect(detachedContextIdentitySchema)(content).pipe( + Schema.decodeEffect(detachedContextIdentitySchema)(content).pipe( Effect.mapError( (error) => new InvalidManagedIdentityError({ @@ -204,7 +206,7 @@ const readDetachedContextId = ( Effect.flatMap((content) => decodeDetachedContextId(content)), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed<string | undefined>(undefined) + ? Effect.map(Effect.void, () => undefined) : Effect.fail(inaccessibleIdentity("Detached context identity", error)), ), ); @@ -229,6 +231,8 @@ export const ensureDetachedContextIdentity = ( const markerPath = gitDetachedContextIdentityPath(gitDirectory); const outcome = yield* claimIdentityFile( markerPath, + // Identity markers are durable JSON files shared with older managed workspaces. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- This is the persistence boundary. `${JSON.stringify({ version: DETACHED_CONTEXT_VERSION, contextId }, null, 2)}\n`, "Detached context identity", 0o600, @@ -236,11 +240,9 @@ export const ensureDetachedContextIdentity = ( if (outcome === "claimed") return { contextId, created: true }; const winner = yield* readDetachedContextId(gitDirectory); if (winner === undefined) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: "Detached context publication raced without a winning marker", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Detached context publication raced without a winning marker", + }); } return { contextId: winner, created: false }; }), @@ -254,7 +256,7 @@ const checkoutLocationSchema = Schema.fromJsonString( ); const decodeLocation = (content: string): Effect.Effect<string, InvalidManagedIdentityError> => - Schema.decodeUnknownEffect(checkoutLocationSchema)(content).pipe( + Schema.decodeEffect(checkoutLocationSchema)(content).pipe( Effect.mapError( (error) => new InvalidManagedIdentityError({ @@ -274,7 +276,7 @@ export const readGitCheckoutLocation = ( Effect.flatMap((content) => decodeLocation(content)), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed<string | undefined>(undefined) + ? Effect.map(Effect.void, () => undefined) : Effect.fail(inaccessibleIdentity("Git checkout location", error)), ), ); @@ -310,6 +312,8 @@ export const ensureGitCheckoutLocation = ( const markerPath = gitCheckoutLocationPath(gitDirectory); const outcome = yield* claimIdentityFile( markerPath, + // Identity markers are durable JSON files shared with older managed workspaces. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- This is the persistence boundary. `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, "Git checkout location", 0o600, @@ -317,11 +321,9 @@ export const ensureGitCheckoutLocation = ( if (outcome === "claimed") return { workspacePath, created: true }; const winner = yield* readGitCheckoutLocation(gitDirectory); if (winner === undefined) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: "Checkout location publication raced without a winning marker", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Checkout location publication raced without a winning marker", + }); } return { workspacePath: winner, created: false }; }), @@ -345,16 +347,16 @@ export const updateGitCheckoutLocationOwned = ( const markerPath = gitCheckoutLocationPath(gitDirectory); const current = yield* readGitCheckoutLocation(gitDirectory); if (current === undefined || current !== expectedPath) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: "Git checkout location changed before repair publication", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: "Git checkout location changed before repair publication", + }); } const temporaryPath = `${markerPath}.tmp.${randomUUID()}`; const publication = writeTemporary( fs, temporaryPath, + // Identity markers are durable JSON files shared with older managed workspaces. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- This is the persistence boundary. `${JSON.stringify({ version: 1, workspacePath }, null, 2)}\n`, ).pipe(Effect.andThen(fs.rename(temporaryPath, markerPath))); yield* Effect.ensuring( diff --git a/packages/stack/src/managed/lifecycle.ts b/packages/stack/src/managed/lifecycle.ts index bc1bf1c1ab..bd4c6f9d1a 100644 --- a/packages/stack/src/managed/lifecycle.ts +++ b/packages/stack/src/managed/lifecycle.ts @@ -56,7 +56,7 @@ const stackIdForInput = ( const stackName = yield* validateManagedStackName(input.stackName ?? "default"); const discovery = yield* manager.discoverWorkspace(input.workspacePath); if (discovery.state === "needsRepair") { - return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); + return yield* workspaceRepairConflict(discovery.reason); } return deriveStackId(discovery.identity, stackName); }); @@ -76,7 +76,7 @@ export const resolveManagedDocument = ( ...(input.stackName === undefined ? {} : { stackName: input.stackName }), portDocument: input.portDocument ?? emptyPortDocument(), }); - return document === undefined ? yield* Effect.fail(noRunningStack(input)) : document; + return document === undefined ? yield* noRunningStack(input) : document; }); class ManagedStopPending extends Data.TaggedError("ManagedStopPending")<{}> {} @@ -96,26 +96,24 @@ export const connectManagedStack = ( (document.lifecycle !== "running" && document.lifecycle !== "starting") || (document.lifecycle === "running" && document.runtime?.controlEndpoint === undefined) ) { - return yield* Effect.fail(noRunningStack(input)); + return yield* noRunningStack(input); } const manager = yield* ManagedStackManager; const probe = yield* manager.probeControl(document.id); if (probe === undefined) { - return yield* Effect.fail(noRunningStack(input)); + return yield* noRunningStack(input); } if (probe.status.daemonCliVersion !== input.cliVersion) { - return yield* Effect.fail( - new DaemonUpgradeRequired({ - stackId: document.id, - oldCliVersion: probe.status.daemonCliVersion, - newCliVersion: input.cliVersion, - state: probe.status.state, - ready: probe.status.ready, - }), - ); + return yield* new DaemonUpgradeRequired({ + stackId: document.id, + oldCliVersion: probe.status.daemonCliVersion, + newCliVersion: input.cliVersion, + state: probe.status.state, + ready: probe.status.ready, + }); } if (probe.status.state !== "running" || !probe.status.ready) { - return yield* Effect.fail(noRunningStack(input)); + return yield* noRunningStack(input); } const client = yield* HttpTransportClient; return RemoteStack.layer(probe.endpoint, { @@ -144,11 +142,9 @@ export const stopManagedStack = ( const stackId = document.id; const revalidatedStackId = yield* stackIdForInput(manager, input); if (revalidatedStackId !== stackId) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed before stop", - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before stop", + }); } const cleanupOwned = (owned: import("./control.ts").ControlOwnership) => Effect.ensuring( @@ -188,18 +184,16 @@ export const stopManagedStack = ( const acquisition = yield* manager.acquireControl(stackId); const currentStackId = yield* stackIdForInput(manager, input); if (currentStackId !== stackId) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed while stopping", - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed while stopping", + }); } if (isControlOwnership(acquisition)) { yield* cleanupOwned(acquisition); return; } yield* acquisition.requestStop; - return yield* Effect.fail(new ManagedStopPending()); + return yield* new ManagedStopPending(); }).pipe( Effect.retry({ schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "30 seconds" })), @@ -228,6 +222,7 @@ export const deleteManagedStack = ( Effect.gen(function* () { const lifecycle = yield* SupervisorLifecycle.make({ ownershipId: stackId, + // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect -- Native UUID generation is the managed control-session ownership boundary. ownerSessionId: crypto.randomUUID(), daemonCliVersion: "managed", }); @@ -259,18 +254,20 @@ export const deleteManagedStack = ( const result = yield* Effect.gen(function* () { const revalidatedStackId = yield* stackIdForInput(manager, input); if (revalidatedStackId !== stackId) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed before delete", - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed before delete", + }); } yield* lifecycle.beginDeleting; return yield* manager.deleteStack(stackId, acquisition); }) - .pipe(Effect.ensuring(acquisition.close)) - .pipe(Effect.ensuring(lifecycle.requestShutdown("dispose").pipe(Effect.ignore))); - if (result.outcome === "already-absent") return yield* Effect.fail(noRunningStack(input)); + // SupervisorLifecycle intentionally preserves arbitrary teardown causes at this boundary. + .pipe( + Effect.ensuring(acquisition.close), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Teardown is best-effort and SupervisorLifecycle exposes its foreign cause unchanged. + Effect.ensuring(lifecycle.requestShutdown("dispose").pipe(Effect.ignore)), + ); + if (result.outcome === "already-absent") return yield* noRunningStack(input); }), ); }); @@ -299,7 +296,7 @@ export const updateManagedLaunch = ( const acquisition = yield* manager.acquireControl(document.id); if (!isControlOwnership(acquisition)) { if (document.lifecycle !== "running" || document.runtime?.controlEndpoint === undefined) { - return yield* Effect.fail(new ManagedStackAttachedError({ stackId: document.id })); + return yield* new ManagedStackAttachedError({ stackId: document.id }); } const status = yield* acquisition.ownerStatus; yield* updateRemoteLaunch( @@ -317,7 +314,7 @@ export const updateManagedLaunch = ( input.launch, ); const next = yield* manager.inspectStack(document.id); - if (next === undefined) return yield* Effect.fail(noRunningStack(input)); + if (next === undefined) return yield* noRunningStack(input); return next; } const update: ManagedStackLaunchUpdateRequest = { diff --git a/packages/stack/src/managed/manager.ts b/packages/stack/src/managed/manager.ts index 11153db265..1b269bff9e 100644 --- a/packages/stack/src/managed/manager.ts +++ b/packages/stack/src/managed/manager.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. +// oxlint-disable effecttsgo/lazy-effect -- Manager operations remain callable to defer resource acquisition until invocation. import { createHash } from "node:crypto"; import { Context, @@ -260,6 +262,7 @@ export class ManagedStackManager extends Context.Service< ManagedStackManagerShape >()("stack/managed/ManagedStackManager") {} +// oxlint-disable-next-line effecttsgo/global-date -- Durable lifecycle documents use host wall-clock timestamps. const now = (): string => new Date().toISOString(); const lengthPrefixed = (value: string): Uint8Array => { @@ -463,9 +466,7 @@ const makeManager = ( .stat(persistedPath) .pipe( Effect.catchTag("PlatformError", (error) => - Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed(undefined) - : Effect.fail(error), + Predicate.isTagged(error.reason, "NotFound") ? Effect.void : Effect.fail(error), ), ); if (persistedInfo === undefined || persistedInfo.type !== "Directory") continue; @@ -486,12 +487,10 @@ const makeManager = ( marker.checkoutId === discovery.identity.checkoutId && marker.contextId === discovery.identity.contextId ) { - return yield* Effect.fail( - new InvalidManagedIdentityError({ - message: - "This ordinary workspace identity is already in use at another folder. Delete the current copied folder's .supabase/identity.json so a new identity can be generated.", - }), - ); + return yield* new InvalidManagedIdentityError({ + message: + "This ordinary workspace identity is already in use at another folder. Delete the current copied folder's .supabase/identity.json so a new identity can be generated.", + }); } } }); @@ -540,12 +539,10 @@ const makeManager = ( (entry) => entry.configKey === invalidInactiveAutomatic?.key, )?.field; if (invalidPersistedField !== undefined && invalidPersistedPort !== undefined) { - return yield* Effect.fail( - new ManagedPortAllocationError({ - fields: [invalidPersistedField], - cause: `Persisted automatic port ${invalidPersistedPort} is reserved for managed control endpoints`, - }), - ); + return yield* new ManagedPortAllocationError({ + fields: [invalidPersistedField], + cause: `Persisted automatic port ${invalidPersistedPort} is reserved for managed control endpoints`, + }); } const strictReserved = new Set<number>(); const exactReserved = new Set<number>( @@ -606,13 +603,11 @@ const makeManager = ( }); for (const assignment of requestedAssignments) { if (!exactReserved.has(assignment.port)) continue; - return yield* Effect.fail( - new ManagedExactPortOccupiedError({ - key: assignment.key, - port: assignment.port, - stackId: request.stackId, - }), - ); + return yield* new ManagedExactPortOccupiedError({ + key: assignment.key, + port: assignment.port, + stackId: request.stackId, + }); } for (const assignment of requestedAssignments) { const owner = (owners.get(assignment.port) ?? []).find((candidate) => { @@ -628,21 +623,19 @@ const makeManager = ( }); }); if (owner !== undefined) { - return yield* Effect.fail(conflictError(request.stackId, assignment, owner.document)); + return yield* conflictError(request.stackId, assignment, owner.document); } const inactiveOwner = plan.inactiveAssignments.find( (candidate) => candidate.port === assignment.port, ); if (inactiveOwner !== undefined && assignment.intent === "exact") { - return yield* Effect.fail( - new ManagedExactPortOccupiedError({ - key: assignment.key, - port: assignment.port, - stackId: request.stackId, - ownerStackId: request.stackId, - ownerKey: inactiveOwner.key, - }), - ); + return yield* new ManagedExactPortOccupiedError({ + key: assignment.key, + port: assignment.port, + stackId: request.stackId, + ownerStackId: request.stackId, + ownerKey: inactiveOwner.key, + }); } } const allocation = yield* withManagedPortLease( @@ -688,7 +681,7 @@ const makeManager = ( const discovery = yield* provideDependencies(discoverEnvironment(request.workspacePath)); yield* validateOrdinaryWorkspaceIdentity(discovery); if (discovery.state === "needsRepair") { - return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); + return yield* workspaceRepairConflict(discovery.reason); } const stackId = deriveStackId(discovery.identity, stackName); const existing = yield* store.read(stackId); @@ -710,7 +703,7 @@ const makeManager = ( const discovery = yield* provideDependencies(ensureEnvironment(request.workspacePath)); yield* validateOrdinaryWorkspaceIdentity(discovery); if (discovery.state === "needsRepair") { - return yield* Effect.fail(workspaceRepairConflict(discovery.reason)); + return yield* workspaceRepairConflict(discovery.reason); } const stackId = deriveStackId(discovery.identity, stackName); const repairId = deriveRepairOwnershipId(discovery.identity); @@ -741,15 +734,13 @@ const makeManager = ( const refreshed = yield* provideDependencies(ensureEnvironment(request.workspacePath)); yield* validateOrdinaryWorkspaceIdentity(refreshed); if (refreshed.state === "needsRepair") { - return yield* Effect.fail(workspaceRepairConflict(refreshed.reason)); + return yield* workspaceRepairConflict(refreshed.reason); } const refreshedStackId = deriveStackId(refreshed.identity, stackName); if (refreshedStackId !== stackId) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Workspace identity changed while resolving the stack", - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + reason: "Workspace identity changed while resolving the stack", + }); } yield* requireOwnedForStack(request.ownership, refreshedStackId); const existing = yield* store.read(refreshedStackId); @@ -807,7 +798,7 @@ const makeManager = ( yield* requireOwnedForStack(ownership, update.stackId); const current = yield* store.read(update.stackId); if (current === undefined) { - return yield* Effect.fail(new ManagedStackNotFoundError({ stackId: update.stackId })); + return yield* new ManagedStackNotFoundError({ stackId: update.stackId }); } let next: ManagedStackDocument = { ...current, @@ -845,7 +836,7 @@ const makeManager = ( yield* requireOwnedForStack(ownership, update.stackId); const current = yield* store.read(update.stackId); if (current === undefined) { - return yield* Effect.fail(new ManagedStackNotFoundError({ stackId: update.stackId })); + return yield* new ManagedStackNotFoundError({ stackId: update.stackId }); } const metadata = { versions: update.launch.versions, @@ -882,18 +873,16 @@ const makeManager = ( Effect.scoped( Effect.gen(function* () { if (request.reason === "duplicate") { - return yield* Effect.fail(workspaceRepairConflict("duplicate")); + return yield* workspaceRepairConflict("duplicate"); } const repairId = deriveRepairOwnershipId(request.identity); const repairAcquisition = yield* provideDependencies( acquireControl({ stackId: repairId }), ); if (!isOwned(repairAcquisition)) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Workspace repair is already owned", - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + reason: "Workspace repair is already owned", + }); } yield* provideDependencies(validateEnvironmentRepair(request)); const listings = yield* store.list(); @@ -912,23 +901,19 @@ const makeManager = ( acquireControl({ stackId: document.id }), ); if (!isOwned(acquisition)) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - stackId: document.id, - reason: `Managed stack ${document.id} is attached to a live owner`, - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + stackId: document.id, + reason: `Managed stack ${document.id} is attached to a live owner`, + }); } stackOwners.push(acquisition); } const revalidated = yield* provideDependencies(validateEnvironmentRepair(request)); const inspection = yield* provideDependencies(inspectWorkspace(revalidated.path)); if (inspection.kind !== "git-checkout") { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - reason: "Repair target is not a Git checkout", - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + reason: "Repair target is not a Git checkout", + }); } const updatedAt = now(); const checkoutRoot = revalidated.path; @@ -941,12 +926,10 @@ const makeManager = ( }); for (const { document, escaped } of updates) { if (isAbsolute(escaped) || escaped === ".." || escaped.startsWith("../")) { - return yield* Effect.fail( - new ManagedWorkspaceRepairConflictError({ - stackId: document.id, - reason: `Managed stack ${document.id} has an invalid local project key`, - }), - ); + return yield* new ManagedWorkspaceRepairConflictError({ + stackId: document.id, + reason: `Managed stack ${document.id} has an invalid local project key`, + }); } } for (const { document, projectPath } of updates) { @@ -985,7 +968,7 @@ const makeManager = ( ), Effect.catchTag("PlatformError", (error) => Predicate.isTagged(error.reason, "NotFound") - ? Effect.succeed(undefined) + ? Effect.void : store.remove(stackId).pipe(Effect.as({ outcome: "removed" as const, stackId })), ), ); diff --git a/packages/stack/src/managed/model.ts b/packages/stack/src/managed/model.ts index dc792558ac..413bbaf096 100644 --- a/packages/stack/src/managed/model.ts +++ b/packages/stack/src/managed/model.ts @@ -89,7 +89,7 @@ export const validateManagedStackName = ( if (name.length === 0) { return Effect.fail(new InvalidManagedStackNameError({ name, reason: "empty" })); } - const character = [...name].find((value) => { + const character = Array.from(name).find((value) => { const code = value.codePointAt(0); return code !== undefined && (code <= 0x1f || code === 0x7f); }); diff --git a/packages/stack/src/managed/paths.ts b/packages/stack/src/managed/paths.ts index 9241ccce85..9df6ca8789 100644 --- a/packages/stack/src/managed/paths.ts +++ b/packages/stack/src/managed/paths.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { Effect } from "effect"; @@ -152,7 +153,7 @@ export const assertManagedStackRootEffect = ( const actual = resolve(stackRoot); return actual === expected ? actual - : yield* Effect.fail(new UnsafeManagedStackPathError({ path: stackRoot })); + : yield* new UnsafeManagedStackPathError({ path: stackRoot }); }); export const ordinaryWorkspaceIdentityPath = (workspacePath: string): string => diff --git a/packages/stack/src/managed/store.ts b/packages/stack/src/managed/store.ts index ab046ff048..ba6b06cc9b 100644 --- a/packages/stack/src/managed/store.ts +++ b/packages/stack/src/managed/store.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. +// oxlint-disable effecttsgo/lazy-effect -- Store operations remain callable to defer filesystem effects until invocation. import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { Effect, FileSystem, Path, PlatformError, Predicate } from "effect"; @@ -87,7 +89,7 @@ const decodeAtPath = ( const content = yield* fs.readFileString(documentPath); const document = yield* decodeManagedStackDocument(documentPath, content); if (document.id !== stackId) { - return yield* Effect.fail(new InvalidManagedStackDocumentError({ path: documentPath })); + return yield* new InvalidManagedStackDocumentError({ path: documentPath }); } return document; }); @@ -107,24 +109,24 @@ const makeListEntry = ( const documentPath = yield* managedStackDocumentPathEffect(stateRoot, stackId); return yield* decodeAtPath(fs, documentPath, stackId).pipe( Effect.map((document): ManagedStackListing => ({ id: stackId, status: "healthy", document })), - Effect.catchTag("InvalidManagedStackDocumentError", (cause) => - Effect.succeed<ManagedStackListing>({ - id: stackId, - status: "corrupt", - path: documentPath, - cause, - }), - ), - Effect.catchTag("PlatformError", (error) => - isNotFound(error) - ? Effect.succeed(undefined) - : Effect.succeed<ManagedStackListing>({ - id: stackId, - status: "corrupt", - path: documentPath, - cause: error, - }), - ), + Effect.catchTags({ + InvalidManagedStackDocumentError: (cause) => + Effect.succeed<ManagedStackListing>({ + id: stackId, + status: "corrupt", + path: documentPath, + cause, + }), + PlatformError: (error) => + isNotFound(error) + ? Effect.map(Effect.void, () => undefined) + : Effect.succeed<ManagedStackListing>({ + id: stackId, + status: "corrupt", + path: documentPath, + cause: error, + }), + }), ); }); @@ -150,7 +152,7 @@ export const makeStackStore = ( const documentPath = yield* managedStackDocumentPathEffect(resolvedStateRoot, stackId); return yield* decodeAtPath(fs, documentPath, stackId).pipe( Effect.catchTag("PlatformError", (error) => - isNotFound(error) ? Effect.succeed(undefined) : Effect.fail(error), + isNotFound(error) ? Effect.map(Effect.void, () => undefined) : Effect.fail(error), ), ); }); @@ -176,7 +178,7 @@ export const makeStackStore = ( names.map((name) => managedStackPathsEffect(resolvedStateRoot, name).pipe( Effect.map(() => name), - Effect.catchTag("InvalidManagedIdentityError", () => Effect.succeed(undefined)), + Effect.catchTag("InvalidManagedIdentityError", () => Effect.void), ), ), ); diff --git a/packages/stack/src/node-entrypoint.integration.test.ts b/packages/stack/src/node-entrypoint.integration.test.ts index 195a82be3b..80a158c87c 100644 --- a/packages/stack/src/node-entrypoint.integration.test.ts +++ b/packages/stack/src/node-entrypoint.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { execFileSync } from "node:child_process"; import { expect, test } from "vitest"; diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index 839f944362..418c58e6f4 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Public Node package APIs intentionally expose Promise facades over Effect programs. import { NodeServices } from "@effect/platform-node"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; @@ -57,6 +58,7 @@ export async function prefetch(options?: PrefetchOptions): Promise<PrefetchResul : { ...options, mode: "docker", containerRuntime: runtime.containerRuntime }; return Effect.runPromise( prefetchEffect(resolvedOptions).pipe( + // oxlint-disable-next-line effecttsgo/multiple-effect-provide -- The preparation layer and Node platform layer have ordered service ownership at this package edge. Effect.provide(preparationLayer), Effect.provide(NodeServices.layer), ), diff --git a/packages/stack/src/paths.ts b/packages/stack/src/paths.ts index 58c67cff84..85445adf27 100644 --- a/packages/stack/src/paths.ts +++ b/packages/stack/src/paths.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/packages/stack/src/platform-bun.integration.test.ts b/packages/stack/src/platform-bun.integration.test.ts index a0593f1d14..b6bce5892b 100644 --- a/packages/stack/src/platform-bun.integration.test.ts +++ b/packages/stack/src/platform-bun.integration.test.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { Cause, Deferred, Effect, Exit, Layer, Predicate, Scope } from "effect"; import { describe, expect, test } from "vitest"; import { diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts index e817c05925..32b72561f9 100644 --- a/packages/stack/src/platform-bun.ts +++ b/packages/stack/src/platform-bun.ts @@ -1,3 +1,6 @@ +// oxlint-disable effecttsgo/async-function -- Bun request and Web Streams callbacks are native asynchronous platform boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Native Bun callbacks preserve foreign causes until protocol mapping. +// oxlint-disable effecttsgo/global-fetch-in-effect -- Bun control transport owns the native fetch boundary and maps protocol failures. import { BunServices } from "@effect/platform-bun"; import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; import { fileURLToPath } from "node:url"; @@ -140,14 +143,21 @@ const controlTransport: ControlTransport["Service"] = { }), }); const close = yield* Effect.cached( + // oxlint-disable-next-line effecttsgo/global-error-in-effect-catch -- Native Bun shutdown Promise is a foreign cleanup boundary. Effect.tryPromise({ try: () => { const stopped = server.stop(false); for (const request of activeRpcRequests) request.interrupt(); return stopped; }, - catch: (cause) => cause, - }).pipe(Effect.asVoid, Effect.orDie), + // Bun's server.stop Promise is a foreign platform boundary; normalize only for the surrounding orDie cleanup. + // oxlint-disable-next-line effecttsgo/global-error-in-effect-catch, effecttsgo/global-error-in-effect-failure -- Native Bun shutdown error cannot be represented by the control protocol. + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }).pipe( + Effect.asVoid, + // oxlint-disable-next-line effecttsgo/global-error-in-effect-failure -- Shutdown cleanup intentionally converts the foreign Bun failure into a defect. + Effect.orDie, + ), ); const service = HttpServer.make({ address: { @@ -253,6 +263,8 @@ const controlTransport: ControlTransport["Service"] = { connection: "close", "content-type": "application/json", }, + // The control endpoint speaks JSON over native fetch. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native HTTP protocol boundary. body: JSON.stringify(stopRequest), }), catch: (cause) => diff --git a/packages/stack/src/platform-node.integration.test.ts b/packages/stack/src/platform-node.integration.test.ts index 3c8e5c6149..6253242ae8 100644 --- a/packages/stack/src/platform-node.integration.test.ts +++ b/packages/stack/src/platform-node.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-timers, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { Cause, Effect, Exit } from "effect"; import { createServer, type Server } from "node:http"; import type { Socket } from "node:net"; diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts index 2e1509f06f..f9516fd1f3 100644 --- a/packages/stack/src/platform-node.ts +++ b/packages/stack/src/platform-node.ts @@ -1,3 +1,6 @@ +// oxlint-disable effecttsgo/node-builtin-import -- This module is the native Node process/HTTP boundary and owns the platform resource directly. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Native Node HTTP callbacks preserve foreign causes until protocol mapping. +// oxlint-disable effecttsgo/global-error-in-effect-failure -- Native Node HTTP callbacks translate platform errors at the transport boundary. import { NodeServices } from "@effect/platform-node"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import { createServer } from "node:http"; @@ -247,6 +250,8 @@ const controlTransport: ControlTransport["Service"] = { } }; request.once("error", onRequestError); + // The control endpoint speaks JSON over native Node HTTP. + // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native HTTP protocol boundary. const body = JSON.stringify(stopRequest); request.setHeader("content-type", "application/json"); request.setHeader("content-length", Buffer.byteLength(body)); diff --git a/packages/stack/src/prefetch.unit.test.ts b/packages/stack/src/prefetch.unit.test.ts index bb29935b41..6a0dd1a3a6 100644 --- a/packages/stack/src/prefetch.unit.test.ts +++ b/packages/stack/src/prefetch.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, test } from "vitest"; import { Cause, diff --git a/packages/stack/src/services/docker-cleanup.ts b/packages/stack/src/services/docker-cleanup.ts index 0e9b5a2a8a..4735f6d938 100644 --- a/packages/stack/src/services/docker-cleanup.ts +++ b/packages/stack/src/services/docker-cleanup.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- This module owns a native subprocess boundary that cannot be expressed through an Effect service. import type { ExternalCleanupAction } from "@supabase/process-compose"; import { execFileSync } from "node:child_process"; import { Effect } from "effect"; diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 41eb366eac..7cf322a3fe 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-console -- This file is emitted as raw Deno source and must remain dependency-free at the runtime boundary. declare const Deno: any; declare const EdgeRuntime: any; diff --git a/packages/stack/src/services/edge-runtime.ts b/packages/stack/src/services/edge-runtime.ts index 6b1c6a9d70..85fe68d8a7 100644 --- a/packages/stack/src/services/edge-runtime.ts +++ b/packages/stack/src/services/edge-runtime.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { ServiceDef } from "@supabase/process-compose"; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index d12004f3aa..09c954c764 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; diff --git a/packages/stack/src/services/vector.ts b/packages/stack/src/services/vector.ts index de4fc9bf93..d9bbaae638 100644 --- a/packages/stack/src/services/vector.ts +++ b/packages/stack/src/services/vector.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Pure path/config helpers use the host path API at a synchronous platform boundary. +// oxlint-disable effecttsgo/process-env -- Docker/Podman socket discovery intentionally reads host runtime environment at the platform boundary. import { accessSync, constants } from "node:fs"; import { dockerNetworkArgs } from "../Platform.ts"; import type { ContainerRuntime } from "../ContainerRuntime.ts"; diff --git a/packages/stack/src/services/vector.unit.test.ts b/packages/stack/src/services/vector.unit.test.ts index 069d232bc9..8cb8fa3319 100644 --- a/packages/stack/src/services/vector.unit.test.ts +++ b/packages/stack/src/services/vector.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stackIdentity } from "../StackIdentity.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; diff --git a/packages/stack/src/supervisor.integration.test.ts b/packages/stack/src/supervisor.integration.test.ts index b16dc89106..8a087dd91d 100644 --- a/packages/stack/src/supervisor.integration.test.ts +++ b/packages/stack/src/supervisor.integration.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-fetch, effecttsgo/global-timers, effecttsgo/multiple-effect-provide, effecttsgo/new-promise, effecttsgo/node-builtin-import, effecttsgo/process-env -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { Cause, Context, Effect, Exit, Layer, Schema } from "effect"; import { NodeFileSystem, NodePath } from "@effect/platform-node"; import { fork, type ChildProcess } from "node:child_process"; diff --git a/packages/stack/src/supervisor.ts b/packages/stack/src/supervisor.ts index 488e0e7651..9aae93f460 100644 --- a/packages/stack/src/supervisor.ts +++ b/packages/stack/src/supervisor.ts @@ -1,3 +1,4 @@ +// oxlint-disable-next-line effecttsgo/node-builtin-import -- The supervisor owns the native Node IPC process boundary. import { fork, type ChildProcess } from "node:child_process"; import { Cause, @@ -485,7 +486,9 @@ const startDaemon = (input: { const appLayer = input.platform.runtimeLayer === undefined ? foregroundLayer(input.config, input.platform.platformFactory, input.lease) - : yield* input.platform.runtimeLayer({ config: input.config, lease: input.lease }); + : // Platform-owned runtime layers preserve their native startup failures. + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Runtime implementations are foreign platform boundaries. + yield* input.platform.runtimeLayer({ config: input.config, lease: input.lease }); const appServices = yield* Layer.buildWithScope(appLayer, input.scope); const localStack = Context.get(appServices, Stack); const localLifecycle = Context.get(appServices, LocalStackLifecycle); @@ -516,12 +519,12 @@ const runManaged = ( yield* validateManagedStackName(input.stackName); const configInput = toDaemonConfig(input.config); if (configInput === undefined) { - return yield* Effect.fail( - new SupervisorStartError({ message: "Supervisor config is missing cwd" }), - ); + return yield* new SupervisorStartError({ message: "Supervisor config is missing cwd" }); } const supervisorLifecycle = yield* SupervisorLifecycle.make({ ownershipId: input.stackId, + // Native session ids fence ownership across detached supervisor processes. + // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect -- This is a process-boundary identifier, not domain randomness. ownerSessionId: crypto.randomUUID(), daemonCliVersion: input.cliVersion, }); @@ -535,7 +538,7 @@ const runManaged = ( new StackBuildError({ detail: "Managed launch updates require an owned supervisor" }), ); } - return Schema.decodeUnknownEffect(managedStackLaunchUpdateSchema)(launch).pipe( + return Schema.decodeEffect(managedStackLaunchUpdateSchema)(launch).pipe( Effect.mapError((cause) => new StackBuildError({ detail: causeMessage(cause) })), Effect.flatMap((decoded) => currentManager.updateLaunch(currentOwner, { stackId, launch: decoded }), @@ -590,6 +593,7 @@ const runManaged = ( const discoveryResult = yield* isControlOwnership(initialAcquisition) ? Effect.raceFirst( discovered, + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Awaiting lifecycle shutdown preserves teardown Causes. supervisorLifecycle.awaitShutdown.pipe(Effect.as({ _tag: "stopped" as const })), ) : discovered; @@ -599,9 +603,9 @@ const runManaged = ( } const stackId = deriveStackId(discoveryResult.discovery.identity, input.stackName); if (stackId !== input.stackId) { - return yield* Effect.fail( - new SupervisorStartError({ message: "Workspace identity changed before supervisor start" }), - ); + return yield* new SupervisorStartError({ + message: "Workspace identity changed before supervisor start", + }); } const requestedMode = configInput.mode ?? input.launch?.mode; let effectiveConfigInput = configInput; @@ -614,11 +618,9 @@ const runManaged = ( requestedMode !== undefined && persistedRuntime.mode !== requestedMode ) { - return yield* Effect.fail( - new SupervisorStartError({ - message: `Stack runtime is already ${persistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, - }), - ); + return yield* new SupervisorStartError({ + message: `Stack runtime is already ${persistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }); } let attachedOwnerWasStopping = false; const initiallyAttached = isControlAttached(initialAcquisition); @@ -679,15 +681,13 @@ const runManaged = ( attachedOwnerWasStopping = attachedStatus.state === "stopping"; if (attachedStatus.daemonCliVersion !== input.cliVersion) { if (input.type !== "upgrade-restart") { - return yield* Effect.fail( - new DaemonUpgradeRequired({ - stackId, - oldCliVersion: attachedStatus.daemonCliVersion, - newCliVersion: input.cliVersion, - state: attachedStatus.state, - ready: attachedStatus.ready, - }), - ); + return yield* new DaemonUpgradeRequired({ + stackId, + oldCliVersion: attachedStatus.daemonCliVersion, + newCliVersion: input.cliVersion, + state: attachedStatus.state, + ready: attachedStatus.ready, + }); } upgradeRestarting = true; const restart = yield* restartIncompatibleOwner({ @@ -698,10 +698,9 @@ const runManaged = ( manager, controlTransport, resolutionTimeout: platform.resolutionTimeout ?? SUPERVISOR_STARTUP_TIMEOUT, - reacquire: () => - reacquireAfterDeath().pipe( - Effect.catchTag("SupervisorOwnerReacquirePending", () => Effect.never), - ), + reacquire: reacquireAfterDeath().pipe( + Effect.catchTag("SupervisorOwnerReacquirePending", () => Effect.never), + ), }); oldSessionEnded = restart.oldSessionEnded; attachedOwnerWasStopping = restart.attachedOwnerWasStopping; @@ -729,11 +728,9 @@ const runManaged = ( const revalidated = yield* manager.ensureWorkspace(input.workspacePath); const revalidatedStackId = deriveStackId(revalidated.identity, input.stackName); if (revalidatedStackId !== stackId) { - return yield* Effect.fail( - new SupervisorStartError({ - message: "Workspace identity changed before supervisor attach", - }), - ); + return yield* new SupervisorStartError({ + message: "Workspace identity changed before supervisor attach", + }); } } if (isControlAttached(acquisition)) { @@ -750,11 +747,9 @@ const runManaged = ( (attachedPersistedRuntime === undefined || attachedPersistedRuntime.mode !== requestedMode) ) { const observedMode = attachedPersistedRuntime?.mode ?? "unknown"; - return yield* Effect.fail( - new SupervisorStartError({ - message: `Stack runtime is already ${observedMode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, - }), - ); + return yield* new SupervisorStartError({ + message: `Stack runtime is already ${observedMode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }); } const attachedStatus = yield* acquisition.ownerStatus; yield* sendMessage({ @@ -776,12 +771,10 @@ const runManaged = ( if (initiallyAttached && !attachedOwnerWasStopping) { if (ownedExisting?.lifecycle === "stopped" && ownedExisting.stopIntent === "explicit") { yield* ownership.close; - return yield* Effect.fail( - new SupervisorStartError({ - message: OWNER_STOPPED_AFTER_TAKEOVER, - reason: "owner-stopped", - }), - ); + return yield* new SupervisorStartError({ + message: OWNER_STOPPED_AFTER_TAKEOVER, + reason: "owner-stopped", + }); } } const ownedPersistedRuntime = @@ -791,11 +784,9 @@ const runManaged = ( requestedMode !== undefined && ownedPersistedRuntime.mode !== requestedMode ) { - return yield* Effect.fail( - new SupervisorStartError({ - message: `Stack runtime is already ${ownedPersistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, - }), - ); + return yield* new SupervisorStartError({ + message: `Stack runtime is already ${ownedPersistedRuntime.mode}; requested ${requestedMode}. Delete and recreate the stack (removing its managed data) before changing execution mode.`, + }); } const runtime = ownedPersistedRuntime === undefined @@ -825,6 +816,8 @@ const runManaged = ( runtime.mode === "native" ? { ...launchInput, mode: "native" } : { ...launchInput, mode: "docker", containerRuntime: runtime.containerRuntime }; + // The startup transaction includes the platform runtime layer and preserves its Cause. + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Runtime startup failures cross a platform-owned boundary. const startup = Effect.gen(function* () { if ( ownedExisting !== undefined && @@ -870,6 +863,7 @@ const runManaged = ( stackId: started.stack.id, lifecycle: "starting", }); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The platform runtime layer preserves its native startup Cause. const built = yield* startDaemon({ config, lease: started.lease, @@ -886,8 +880,9 @@ const runManaged = ( yield* Effect.forkIn( built.localLifecycle.awaitDisposed.pipe( Effect.andThen(lifecycle.fail("Local stack disposed unexpectedly")), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Lifecycle shutdown preserves the runtime's exact Cause. Effect.andThen(lifecycle.requestShutdown("dispose")), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ), scope, ); @@ -911,8 +906,11 @@ const runManaged = ( process.disconnect?.(); return { started, built }; }); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The startup transaction preserves platform and lifecycle Causes. const startupResult = yield* Effect.raceFirst( + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The startup transaction preserves platform and lifecycle Causes. startup.pipe(Effect.map((result) => ({ _tag: "started" as const, ...result }))), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Awaiting lifecycle shutdown preserves teardown Causes. (lifecycle?.awaitShutdown ?? Effect.never).pipe(Effect.as({ _tag: "stopped" as const })), ); if (Predicate.isTagged(startupResult, "stopped")) { @@ -921,9 +919,11 @@ const runManaged = ( } const shutdown = yield* Effect.raceFirst( waitForSignal().pipe(Effect.as("signal" as const)), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Awaiting lifecycle shutdown preserves teardown Causes. (lifecycle?.awaitShutdown ?? Effect.never).pipe(Effect.as("shutdown" as const)), ); if (lifecycle !== undefined && shutdown === "signal") { + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Lifecycle shutdown preserves the runtime's exact Cause. yield* lifecycle.requestShutdown("signal"); } }).pipe( @@ -931,6 +931,7 @@ const runManaged = ( const typed = Cause.findError(cause); const failure = Result.isSuccess(typed) ? typed.success : undefined; if (failure instanceof SupervisorStartError && failure.reason === "owner-stopped") { + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Preserve the original startup Cause unchanged. return Effect.failCause(cause); } const canMapRestart = @@ -940,16 +941,17 @@ const runManaged = ( !Cause.hasDies(cause) && !Cause.hasInterrupts(cause); const failureDetail = failure === undefined ? causeMessage(cause) : causeMessage(failure); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Lifecycle finalization preserves exact teardown Causes. const finalizeFailure = lifecycle === undefined ? Effect.void - : lifecycle - .setClose(owner?.close ?? Effect.void) - .pipe( - Effect.andThen(lifecycle.fail(failureDetail)), - Effect.andThen(lifecycle.requestShutdown("startup-failure")), - ); + : lifecycle.setClose(owner?.close ?? Effect.void).pipe( + Effect.andThen(lifecycle.fail(failureDetail)), + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Lifecycle shutdown preserves the runtime's exact Cause. + Effect.andThen(lifecycle.requestShutdown("startup-failure")), + ); if (!claimedStack || owner === undefined || managerService === undefined) { + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Preserve the original startup Cause after finalization. return finalizeFailure.pipe( Effect.andThen( canMapRestart @@ -960,7 +962,8 @@ const runManaged = ( detail: failureDetail, }), ) - : Effect.failCause(cause), + : // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Preserve the original startup Cause after finalization. + Effect.failCause(cause), ), ); } @@ -969,8 +972,9 @@ const runManaged = ( stackId: owner.ownershipId, lifecycle: "failed", }) - .pipe(Effect.andThen(finalizeFailure)) .pipe( + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Finalization preserves lifecycle teardown Causes. + Effect.andThen(finalizeFailure), Effect.matchCauseEffect({ onFailure: () => canMapRestart @@ -981,7 +985,8 @@ const runManaged = ( detail: causeMessage(failure), }), ) - : Effect.failCause(cause), + : // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Preserve the original startup Cause after finalization. + Effect.failCause(cause), onSuccess: () => canMapRestart ? Effect.fail( @@ -991,7 +996,8 @@ const runManaged = ( detail: causeMessage(failure), }), ) - : Effect.failCause(cause), + : // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- Preserve the original startup Cause after finalization. + Effect.failCause(cause), }), ); }), @@ -1003,7 +1009,7 @@ export const runSupervisor = ( platform: SupervisorPlatform, ): Effect.Effect< void, - SupervisorStartError | unknown, + unknown, | ControlTransport | import("effect").FileSystem.FileSystem | import("effect").Path.Path @@ -1013,8 +1019,10 @@ export const runSupervisor = ( Effect.gen(function* () { const scope = yield* Effect.scope; const input = yield* receiveStartMessage(); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The child process boundary forwards the supervisor's exact Cause. yield* Effect.matchCauseEffect(runManaged(input, platform, scope), { onFailure: (cause) => + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The child process boundary forwards the exact supervisor Cause. sendMessage(supervisorErrorMessage(cause)).pipe(Effect.andThen(Effect.failCause(cause))), onSuccess: Effect.succeed, }); @@ -1177,15 +1185,13 @@ export const supervisorLayer = ( yield* sendStart(child, input); const response = yield* Fiber.join(responseFiber); if (response.owner.daemonCliVersion !== input.cliVersion) { - return yield* Effect.fail( - new DaemonUpgradeRequired({ - stackId: input.stackId, - oldCliVersion: response.owner.daemonCliVersion, - newCliVersion: input.cliVersion, - state: response.owner.state, - ready: response.owner.ready, - }), - ); + return yield* new DaemonUpgradeRequired({ + stackId: input.stackId, + oldCliVersion: response.owner.daemonCliVersion, + newCliVersion: input.cliVersion, + state: response.owner.state, + ready: response.owner.ready, + }); } child.unref(); detached = true; diff --git a/packages/stack/src/terminateChild.unit.test.ts b/packages/stack/src/terminateChild.unit.test.ts index 7887fd2aa8..2385c0229a 100644 --- a/packages/stack/src/terminateChild.unit.test.ts +++ b/packages/stack/src/terminateChild.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, it, vi } from "vitest"; import { Effect, Fiber } from "effect"; import { terminateChildProcess } from "./terminateChild.ts"; diff --git a/packages/stack/tests/createStack-docker.e2e.test.ts b/packages/stack/tests/createStack-docker.e2e.test.ts index f2882266a0..8a051629c2 100644 --- a/packages/stack/tests/createStack-docker.e2e.test.ts +++ b/packages/stack/tests/createStack-docker.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { execSync } from "node:child_process"; import { mkdtempSync, rmSync } from "node:fs"; diff --git a/packages/stack/tests/createStack-native.e2e.test.ts b/packages/stack/tests/createStack-native.e2e.test.ts index ed582221d9..2843cb1a3a 100644 --- a/packages/stack/tests/createStack-native.e2e.test.ts +++ b/packages/stack/tests/createStack-native.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { createClient } from "@supabase/supabase-js"; import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 3afdaa7a57..af2abc737c 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/packages/stack/tests/global-setup.ts b/packages/stack/tests/global-setup.ts index 108d681c57..50127dd591 100644 --- a/packages/stack/tests/global-setup.ts +++ b/packages/stack/tests/global-setup.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { warmStackE2eDependencies } from "./helpers/warmup.ts"; export async function setup(): Promise<void> { diff --git a/packages/stack/tests/helpers/compiled-supervisor-parent.ts b/packages/stack/tests/helpers/compiled-supervisor-parent.ts index b41b1fb555..ea9eb2d6f0 100644 --- a/packages/stack/tests/helpers/compiled-supervisor-parent.ts +++ b/packages/stack/tests/helpers/compiled-supervisor-parent.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/new-promise, effecttsgo/process-env -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { Context, Effect, Layer, Schema } from "effect"; import { runTestSupervisor } from "./supervisor-child.ts"; import { Stack } from "../../src/Stack.ts"; diff --git a/packages/stack/tests/helpers/e2e.ts b/packages/stack/tests/helpers/e2e.ts index c62180b672..867a1e3e0d 100644 --- a/packages/stack/tests/helpers/e2e.ts +++ b/packages/stack/tests/helpers/e2e.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/global-date, effecttsgo/global-fetch -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. /** * Poll an Edge Function endpoint until the gateway can actually serve it. * diff --git a/packages/stack/tests/helpers/file-watch.ts b/packages/stack/tests/helpers/file-watch.ts index 783de7cf8c..ae6726ed4e 100644 --- a/packages/stack/tests/helpers/file-watch.ts +++ b/packages/stack/tests/helpers/file-watch.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { watch, type FSWatcher } from "node:fs"; /** diff --git a/packages/stack/tests/helpers/git-workspace.ts b/packages/stack/tests/helpers/git-workspace.ts index 3462b8ff2d..057f0fb14b 100644 --- a/packages/stack/tests/helpers/git-workspace.ts +++ b/packages/stack/tests/helpers/git-workspace.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; import { devNull, tmpdir } from "node:os"; diff --git a/packages/stack/tests/helpers/managed-manager.ts b/packages/stack/tests/helpers/managed-manager.ts index 735a6b20f8..8bc417b524 100644 --- a/packages/stack/tests/helpers/managed-manager.ts +++ b/packages/stack/tests/helpers/managed-manager.ts @@ -1,3 +1,5 @@ +// oxlint-disable effecttsgo/effect-succeed-with-void, effecttsgo/global-date, effecttsgo/global-date-in-effect, effecttsgo/global-error-in-effect-failure, effecttsgo/lazy-effect, effecttsgo/new-promise, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. +// oxlint-disable effecttsgo/any-unknown-in-error-context -- Integration tests and subprocess fixtures intentionally inspect generic Effect failures at the boundary. import { NodeFileSystem } from "@effect/platform-node"; import { Effect, Predicate, Stream } from "effect"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; diff --git a/packages/stack/tests/helpers/stack-ports.ts b/packages/stack/tests/helpers/stack-ports.ts index 06fe40be4f..960f46af3d 100644 --- a/packages/stack/tests/helpers/stack-ports.ts +++ b/packages/stack/tests/helpers/stack-ports.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { NodeFileSystem } from "@effect/platform-node"; import { Effect } from "effect"; import { createStack, type StackHandle } from "../../src/node.ts"; diff --git a/packages/stack/tests/helpers/supervisor-child.ts b/packages/stack/tests/helpers/supervisor-child.ts index 62c7d1c17d..3c5a256f65 100644 --- a/packages/stack/tests/helpers/supervisor-child.ts +++ b/packages/stack/tests/helpers/supervisor-child.ts @@ -2,7 +2,9 @@ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"; import { BunFileSystem, BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Layer, Stream, Duration } from "effect"; import { createServer, type Server } from "node:net"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- The subprocess fixture owns native filesystem assertions at its test boundary. import { existsSync, writeFileSync } from "node:fs"; +// oxlint-disable-next-line effecttsgo/node-builtin-import -- The subprocess fixture needs native path normalization for its file watcher. import { dirname } from "node:path"; import { runSupervisor, @@ -12,7 +14,7 @@ import { import { LocalStackLifecycle } from "../../src/LocalStack.ts"; import { Stack } from "../../src/Stack.ts"; import { validateResolvedConfig } from "../../src/StackBuilder.ts"; -import { StackReadinessError } from "../../src/errors.ts"; +import { StackBuildError, StackReadinessError } from "../../src/errors.ts"; import { ControlTransport } from "../../src/managed/control.ts"; import { gitConfigStoreLayer } from "../../src/managed/git.ts"; import { ManagedStackManager, managedStackManagerLayer } from "../../src/managed/manager.ts"; @@ -29,6 +31,10 @@ import type { PortLease } from "../../src/PortAllocator.ts"; import type { ResolvedDaemonConfig } from "../../src/StackConfig.ts"; import { watchDirectoryWithRetry } from "./file-watch.ts"; +// The child process receives its test configuration through its inherited environment. +// oxlint-disable-next-line effecttsgo/process-env -- Native subprocess test configuration boundary. +const testEnvironment = process.env; + type TestMode = | "bind-all" | "fail-after-bind" @@ -72,7 +78,7 @@ const waitForFile = (path: string): Effect.Effect<void> => ); const testMode = (): TestMode => { - const value = process.env["SUPABASE_STACK_TEST_RUNTIME_MODE"]; + const value = testEnvironment["SUPABASE_STACK_TEST_RUNTIME_MODE"]; if (value === "fail-after-bind") return value; if (value === "hold-reservations") return value; if (value === "hold-start") return value; @@ -123,7 +129,7 @@ const testStackLayer = ( serviceEndpoints: {}, }; const waitForStopRelease = (): Effect.Effect<void> => { - const path = process.env["SUPABASE_STACK_TEST_STOP_RELEASE_FILE"]; + const path = testEnvironment["SUPABASE_STACK_TEST_STOP_RELEASE_FILE"]; if (path === undefined) return Effect.never; return waitForFile(path); }; @@ -133,7 +139,7 @@ const testStackLayer = ( stop: () => mode === "hold-stop" ? Effect.gen(function* () { - const stageFile = process.env["SUPABASE_STACK_TEST_STOP_BEGAN_FILE"]; + const stageFile = testEnvironment["SUPABASE_STACK_TEST_STOP_BEGAN_FILE"]; if (stageFile !== undefined) { yield* Effect.sync(() => writeFileSync(stageFile, "began")); } @@ -180,7 +186,7 @@ const testRuntime = ({ readonly lease: PortLease; }): Effect.Effect< Layer.Layer<Stack | LocalStackLifecycle>, - unknown, + StackBuildError | SupervisorStartError, import("effect").Scope.Scope > => { const mode = testMode(); @@ -188,7 +194,7 @@ const testRuntime = ({ const disposed = Deferred.makeUnsafe<void>(); yield* validateResolvedConfig(config); if (mode === "hold-start") { - const releaseFile = process.env["SUPABASE_STACK_TEST_START_RELEASE_FILE"]; + const releaseFile = testEnvironment["SUPABASE_STACK_TEST_START_RELEASE_FILE"]; yield* releaseFile === undefined ? Effect.never : waitForFile(releaseFile); } const servers: Array<Server> = []; @@ -202,9 +208,9 @@ const testRuntime = ({ } yield* Effect.addFinalizer(() => closeTestPorts(servers)); if (mode === "fail-after-bind") { - return yield* Effect.fail( - new SupervisorStartError({ message: "Supervisor test runtime failed after binding" }), - ); + return yield* new SupervisorStartError({ + message: "Supervisor test runtime failed after binding", + }); } return Layer.mergeAll( testStackLayer(config, mode, disposed), @@ -227,8 +233,8 @@ const observeAttachedBeforeReady = (value: unknown): Effect.Effect<void> => { ) { return Effect.void; } - const readyFile = process.env["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; - const releaseFile = process.env["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; + const readyFile = testEnvironment["SUPABASE_STACK_TEST_ATTACHED_READY_FILE"]; + const releaseFile = testEnvironment["SUPABASE_STACK_TEST_ATTACHED_RELEASE_FILE"]; if (readyFile === undefined || existsSync(readyFile)) return Effect.void; return Effect.sync(() => writeFileSync(readyFile, "ready")).pipe( Effect.andThen(releaseFile === undefined ? Effect.void : waitForFile(releaseFile)), @@ -236,14 +242,14 @@ const observeAttachedBeforeReady = (value: unknown): Effect.Effect<void> => { }; const resolutionTimeout = (): Duration.Input => { - const milliseconds = Number(process.env["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); + const milliseconds = Number(testEnvironment["SUPABASE_STACK_TEST_STARTUP_TIMEOUT_MS"]); return Number.isFinite(milliseconds) && milliseconds > 0 ? `${milliseconds} millis` : "30 seconds"; }; const testPlatform = (): "node" | "bun" => - process.env["SUPABASE_STACK_TEST_PLATFORM"] === "bun" ? "bun" : "node"; + testEnvironment["SUPABASE_STACK_TEST_PLATFORM"] === "bun" ? "bun" : "node"; const managerLayer = (stateRoot: string, platform: "node" | "bun") => managedStackManagerLayer({ stateRoot, preferCatalogDefaults: false }).pipe( @@ -258,8 +264,8 @@ const managerLayer = (stateRoot: string, platform: "node" | "bun") => ), ), (base) => { - const readyFile = process.env["SUPABASE_STACK_TEST_ENSURE_READY_FILE"]; - const releaseFile = process.env["SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE"]; + const readyFile = testEnvironment["SUPABASE_STACK_TEST_ENSURE_READY_FILE"]; + const releaseFile = testEnvironment["SUPABASE_STACK_TEST_ENSURE_RELEASE_FILE"]; return Layer.effect( ManagedStackManager, ManagedStackManager.pipe( @@ -268,9 +274,9 @@ const managerLayer = (stateRoot: string, platform: "node" | "bun") => startStack: (input: Parameters<typeof manager.startStack>[0]) => manager.startStack(input).pipe( Effect.tap(() => { - const markerFile = process.env["SUPABASE_STACK_TEST_MANAGED_STARTED_FILE"]; + const markerFile = testEnvironment["SUPABASE_STACK_TEST_MANAGED_STARTED_FILE"]; const releaseFile = - process.env["SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE"]; + testEnvironment["SUPABASE_STACK_TEST_MANAGED_STARTED_RELEASE_FILE"]; return Effect.sync(() => { if (markerFile !== undefined) writeFileSync(markerFile, "started"); }).pipe( @@ -317,19 +323,16 @@ export const runTestSupervisor = (): void => { runtimeLayer: testRuntime, resolutionTimeout: resolutionTimeout(), }; + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The child process entrypoint preserves the supervisor's exact Cause. const program = runSupervisor(supervisorPlatform).pipe( - Effect.provide(gitConfigStoreLayer), - Effect.provide(testControlTransportLayer), + Effect.provide(Layer.mergeAll(gitConfigStoreLayer, testControlTransportLayer)), ); - void Effect.runPromise( + const platformLayer = platformKind === "bun" - ? program.pipe(Effect.provide(BunServices.layer), Effect.provide(BunFileSystem.layer)) - : program.pipe( - Effect.provide(NodeServices.layer), - Effect.provide(NodeFileSystem.layer), - Effect.provide(NodePath.layer), - ), - ); + ? Layer.mergeAll(BunServices.layer, BunFileSystem.layer) + : Layer.mergeAll(NodeServices.layer, NodeFileSystem.layer, NodePath.layer); + // oxlint-disable-next-line effecttsgo/any-unknown-in-error-context -- The child process entrypoint forwards the supervisor's exact Cause. + void Effect.runPromise(program.pipe(Effect.provide(platformLayer))); }; if (import.meta.main) runTestSupervisor(); diff --git a/packages/stack/tests/helpers/warmup.ts b/packages/stack/tests/helpers/warmup.ts index 9db0bf2114..e53cb294f0 100644 --- a/packages/stack/tests/helpers/warmup.ts +++ b/packages/stack/tests/helpers/warmup.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { execSync } from "node:child_process"; import { prefetch, type PrefetchOptions, type PrefetchResult } from "../../src/node.ts"; diff --git a/packages/stack/tests/helpers/warmup.unit.test.ts b/packages/stack/tests/helpers/warmup.unit.test.ts index 16ab704f69..589f6b42e9 100644 --- a/packages/stack/tests/helpers/warmup.unit.test.ts +++ b/packages/stack/tests/helpers/warmup.unit.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { describe, expect, test } from "vitest"; import type { PrefetchOptions, PrefetchResult } from "../../src/node.ts"; import { warmStackE2eDependencies } from "./warmup.ts"; diff --git a/packages/stack/tests/postgresDataPersistence.e2e.test.ts b/packages/stack/tests/postgresDataPersistence.e2e.test.ts index 7d7da780a7..3bc930eb7a 100644 --- a/packages/stack/tests/postgresDataPersistence.e2e.test.ts +++ b/packages/stack/tests/postgresDataPersistence.e2e.test.ts @@ -1,3 +1,4 @@ +// oxlint-disable effecttsgo/async-function, effecttsgo/node-builtin-import -- Tests intentionally exercise native async, HTTP, timer, and subprocess boundaries. import { execSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2664cd0f60..3b22a459a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@effect/sql-pg': specifier: 4.0.0-rc.111 version: 4.0.0-rc.111 + '@effect/tsgo': + specifier: 0.36.5 + version: 0.36.5 '@effect/vitest': specifier: 4.0.0-rc.111 version: 4.0.0-rc.111 @@ -71,6 +74,9 @@ importers: .: devDependencies: + '@effect/tsgo': + specifier: 'catalog:' + version: 0.36.5 nx: specifier: 'catalog:' version: 23.1.1 @@ -117,6 +123,9 @@ importers: '@effect/platform-bun': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111) + '@effect/platform-node': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(redis@6.2.1) '@effect/sql-pg': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111) @@ -266,6 +275,9 @@ importers: specifier: workspace:* version: link:../../packages/cli-test-helpers devDependencies: + '@effect/platform-bun': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 @@ -275,6 +287,9 @@ importers: '@vitest/coverage-istanbul': specifier: 'catalog:' version: 4.1.10(vitest@4.1.10) + effect: + specifier: 'catalog:' + version: 4.0.0-rc.111 knip: specifier: 'catalog:' version: 6.32.2 @@ -387,6 +402,13 @@ importers: packages/cli-linux-x64-musl: {} packages/cli-test-helpers: + dependencies: + '@effect/platform-bun': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111) + effect: + specifier: 'catalog:' + version: 4.0.0-rc.111 devDependencies: '@tsconfig/bun': specifier: 'catalog:' @@ -438,6 +460,9 @@ importers: specifier: ^1.8.0 version: 1.8.0 devDependencies: + '@effect/vitest': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) '@tsconfig/bun': specifier: 'catalog:' version: 1.0.11 @@ -468,13 +493,13 @@ importers: packages/process-compose: dependencies: + '@effect/platform-bun': + specifier: 'catalog:' + version: 4.0.0-rc.111(effect@4.0.0-rc.111) effect: specifier: 'catalog:' version: 4.0.0-rc.111 devDependencies: - '@effect/platform-bun': - specifier: 'catalog:' - version: 4.0.0-rc.111(effect@4.0.0-rc.111) '@effect/vitest': specifier: 'catalog:' version: 4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10) @@ -566,6 +591,13 @@ importers: vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) + devDependencies: + '@tsconfig/bun': + specifier: 'catalog:' + version: 1.0.11 + '@types/bun': + specifier: 'catalog:' + version: 1.4.0 packages: @@ -777,6 +809,45 @@ packages: peerDependencies: effect: ^4.0.0-rc.111 + '@effect/tsgo-darwin-arm64@0.36.5': + resolution: {integrity: sha512-+JPS65Ekod5NS41Kg9OIyuUsygNcSw5/4Y+UNbY6Wob6dvP+CkEa51pGBAmHKQp3Z/D3g/A9GNE66ndu9kz8dw==} + cpu: [arm64] + os: [darwin] + + '@effect/tsgo-darwin-x64@0.36.5': + resolution: {integrity: sha512-S67mS1GTSvfeN5Tsij7QarJNuv3q7U/PMhjTbGtKO9mqgDTOA2IOlxq7YPIxkWq+AiOIv0H3aHGk7ToRjcPWxg==} + cpu: [x64] + os: [darwin] + + '@effect/tsgo-linux-arm64@0.36.5': + resolution: {integrity: sha512-bNLzLrQ/4Sf0N7NlAqcEpr+g+RW/ERIvmSFQN9Nu+hSFti4Kx9Nrfo1LJ4V0qASry5Rj2DG4Wa9C5ydQAD9qqA==} + cpu: [arm64] + os: [linux] + + '@effect/tsgo-linux-arm@0.36.5': + resolution: {integrity: sha512-UqtTPUgoVMRHAOcHiK7sddxjUEj6kOkXAlu4Y2TL/MhGiu81ZBzZrg8TXf0ApNeEMOD0EIK8hwU2kik8O+buxA==} + cpu: [arm] + os: [linux] + + '@effect/tsgo-linux-x64@0.36.5': + resolution: {integrity: sha512-QWdyuUcAb1kZBeItycHg1ZKloNbVDtlVm1C0bbDVHqZIZ/d2rqhKi6Zajm64GnHtfdwPTn8Oi1iOCH1IYgo0IQ==} + cpu: [x64] + os: [linux] + + '@effect/tsgo-win32-arm64@0.36.5': + resolution: {integrity: sha512-5tO5em1DfplFz5GqpQVGRSrAoE+kEgIc7Y0ClbT9jkxaK18IFVq9KIYybt4d6VT4+QnNQGdqWOahMcGE4JAZ8w==} + cpu: [arm64] + os: [win32] + + '@effect/tsgo-win32-x64@0.36.5': + resolution: {integrity: sha512-pmaKwdYAIs9GFpMV9glIUks0UZDHE1/xwP6+GCGhSFxB76cDLMnrHr/IBt5VfGddT65SK+Dyw/vAtcX7fRp4KA==} + cpu: [x64] + os: [win32] + + '@effect/tsgo@0.36.5': + resolution: {integrity: sha512-BHxVjeRK1/XlqYHWXkbT4W9JpOrT3sNA2wrfwQPBTojD5OSyxI2TUIltobYhWudyfuCrn770qP6uOpDjdrmghA==} + hasBin: true + '@effect/vitest@4.0.0-rc.111': resolution: {integrity: sha512-YDaEVT+grREBVMzykRNFtJwGxy02achzT0WXZYwMJA4ukzuB9krkgQ5roc3N6zhC3NQq/j4IyM32/Pcov7AhHw==} peerDependencies: @@ -4739,6 +4810,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.9: resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} @@ -7300,6 +7374,37 @@ snapshots: transitivePeerDependencies: - pg-native + '@effect/tsgo-darwin-arm64@0.36.5': + optional: true + + '@effect/tsgo-darwin-x64@0.36.5': + optional: true + + '@effect/tsgo-linux-arm64@0.36.5': + optional: true + + '@effect/tsgo-linux-arm@0.36.5': + optional: true + + '@effect/tsgo-linux-x64@0.36.5': + optional: true + + '@effect/tsgo-win32-arm64@0.36.5': + optional: true + + '@effect/tsgo-win32-x64@0.36.5': + optional: true + + '@effect/tsgo@0.36.5': + optionalDependencies: + '@effect/tsgo-darwin-arm64': 0.36.5 + '@effect/tsgo-darwin-x64': 0.36.5 + '@effect/tsgo-linux-arm': 0.36.5 + '@effect/tsgo-linux-arm64': 0.36.5 + '@effect/tsgo-linux-x64': 0.36.5 + '@effect/tsgo-win32-arm64': 0.36.5 + '@effect/tsgo-win32-x64': 0.36.5 + '@effect/vitest@4.0.0-rc.111(effect@4.0.0-rc.111)(vitest@4.1.10)': dependencies: effect: 4.0.0-rc.111 @@ -7655,7 +7760,7 @@ snapshots: express: 5.2.1 express-rate-limit: 8.6.1(express@5.2.1) hono: 4.12.32 - jose: 6.2.9 + jose: 6.2.8 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -10921,6 +11026,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.8: {} + jose@6.2.9: {} js-tokens@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a36180760..693c8c80f4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: "@effect/platform-bun": "4.0.0-rc.111" "@effect/platform-node": "4.0.0-rc.111" "@effect/sql-pg": "4.0.0-rc.111" + "@effect/tsgo": "0.36.5" "@effect/vitest": "4.0.0-rc.111" "@nx/devkit": "^23.1.1" "@tsconfig/bun": "^1.0.11" @@ -44,6 +45,16 @@ minimumReleaseAgeExclude: - "@effect/platform-node@4.0.0-rc.111" - "@effect/platform-node-shared@4.0.0-rc.111" - "@effect/sql-pg@4.0.0-rc.111" + # @effect/tsgo@0.36.5 and its platform binaries were published within the + # minimum release age window; the Effect linter setup requires this release. + - "@effect/tsgo@0.36.5" + - "@effect/tsgo-darwin-arm64@0.36.5" + - "@effect/tsgo-darwin-x64@0.36.5" + - "@effect/tsgo-linux-arm@0.36.5" + - "@effect/tsgo-linux-arm64@0.36.5" + - "@effect/tsgo-linux-x64@0.36.5" + - "@effect/tsgo-win32-arm64@0.36.5" + - "@effect/tsgo-win32-x64@0.36.5" - "@effect/vitest@4.0.0-rc.111" - "@supabase/pg-delta@1.0.0-alpha.46" - "@supabase/pg-topo@1.0.0-alpha.5" diff --git a/tools/nx-plugins/package.json b/tools/nx-plugins/package.json index 04b95d8394..e89321e7d6 100644 --- a/tools/nx-plugins/package.json +++ b/tools/nx-plugins/package.json @@ -6,5 +6,9 @@ "@nx/devkit": "catalog:", "typescript": "catalog:", "vitest": "catalog:" + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:" } } diff --git a/tools/nx-plugins/src/oxlint.plugin.ts b/tools/nx-plugins/src/oxlint.plugin.ts index 4901e89fa5..97ff7c8fb8 100644 --- a/tools/nx-plugins/src/oxlint.plugin.ts +++ b/tools/nx-plugins/src/oxlint.plugin.ts @@ -13,8 +13,6 @@ export const createNodesV2: CreateNodesV2<OxlintPluginOptions> = [ if (!pkgJson.devDependencies?.["oxlint"]) return []; const projectRoot = dirname(packageJsonPath); - const typeAware = (pkgJson.oxlint as { typeAware?: boolean } | undefined)?.typeAware ?? false; - const typeAwareFlag = typeAware ? "--type-aware " : ""; return [ [ @@ -24,13 +22,19 @@ export const createNodesV2: CreateNodesV2<OxlintPluginOptions> = [ [projectRoot]: { targets: { "lint:check": { - command: `oxlint ${typeAwareFlag}--deny-warnings`, + command: "oxlint --deny-warnings", options: { cwd: "{projectRoot}" }, cache: true, - inputs: ["default", { externalDependencies: ["oxlint"] }], + inputs: [ + "default", + "{workspaceRoot}/.oxlintrc.json", + { + externalDependencies: ["oxlint", "oxlint-tsgolint", "@effect/tsgo"], + }, + ], }, "lint:fix": { - command: `oxlint ${typeAwareFlag}--deny-warnings --fix`, + command: "oxlint --deny-warnings --fix", options: { cwd: "{projectRoot}" }, cache: false, }, diff --git a/tools/nx-plugins/tsconfig.json b/tools/nx-plugins/tsconfig.json index 0e4e192195..11df14236d 100644 --- a/tools/nx-plugins/tsconfig.json +++ b/tools/nx-plugins/tsconfig.json @@ -1,10 +1,8 @@ { + "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "node", - "strict": true, - "skipLibCheck": true + "lib": ["ESNext", "DOM"], + "types": ["bun"] }, "include": ["src/**/*.ts"] }